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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,520 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { clamp } from 'remeda';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import type { RomInfo } from '@/frontend/romScanner';
5
+ import type { NetplayOptions } from '../../App';
6
+ import { DEFAULT_PORT as NETPLAY_DEFAULT_PORT } from '@/netplay';
7
+ import { DiscoveryListener } from '@/netplay/NetplayDiscovery';
8
+ import { useGamepadContext } from '../../GamepadContext';
9
+ import { useClearTerminal } from '../../hooks/useClearTerminal';
10
+ import type { DiscoveredHost } from '..';
11
+ import {
12
+ PORT_MAX,
13
+ DECIMAL_BASE,
14
+ inputDelayOptions,
15
+ DISCOVERY_INITIAL_DELAY_MS,
16
+ DISCOVERY_QUERY_INTERVAL_MS,
17
+ DISCOVERY_HOST_MAX_AGE_MS,
18
+ } from './consts';
19
+
20
+ export * from './consts';
21
+
22
+ // Netplay panel component
23
+ export const NetplayPanel = ({
24
+ rom,
25
+ onStart,
26
+ onCancel,
27
+ initialMode = 'host',
28
+ }: {
29
+ rom: RomInfo;
30
+ onStart: (options: NetplayOptions) => void;
31
+ onCancel: () => void;
32
+ initialMode?: 'host' | 'join';
33
+ }) => {
34
+ const ready = useClearTerminal();
35
+
36
+ // Form state
37
+ const [mode, setMode] = useState<'host' | 'join'>(initialMode);
38
+ const [nickname, setNickname] = useState('Player');
39
+ const [port, setPort] = useState(NETPLAY_DEFAULT_PORT);
40
+ const [hostAddress, setHostAddress] = useState('');
41
+ const [password, setPassword] = useState('');
42
+ const [inputDelay, setInputDelay] = useState(2);
43
+ const [spectate, setSpectate] = useState(false);
44
+
45
+ // LAN discovery state (Join mode only)
46
+ const [discoveredHosts, setDiscoveredHosts] = useState<DiscoveredHost[]>([]);
47
+ const [selectedHostIndex, setSelectedHostIndex] = useState(-1); // -1 = manual entry
48
+ const [isScanning, setIsScanning] = useState(false);
49
+ const discoveryRef = useRef<DiscoveryListener | null>(null);
50
+
51
+ // UI state
52
+ const [selectedField, setSelectedField] = useState(0);
53
+ const [editingField, setEditingField] = useState<string | null>(null);
54
+
55
+ // LAN discovery effect for Join mode
56
+ useEffect(() => {
57
+ if (mode !== 'join') {
58
+ // Stop discovery when switching to host mode
59
+ if (discoveryRef.current) {
60
+ discoveryRef.current.stop();
61
+ discoveryRef.current = null;
62
+ }
63
+ setDiscoveredHosts([]);
64
+ setSelectedHostIndex(-1);
65
+ setIsScanning(false);
66
+ return;
67
+ }
68
+
69
+ // Start discovery listener for Join mode
70
+ const listener = new DiscoveryListener();
71
+ discoveryRef.current = listener;
72
+ setIsScanning(true);
73
+
74
+ listener.start((host) => {
75
+ setDiscoveredHosts(prev => {
76
+ // Update or add host
77
+ const existingIndex = prev.findIndex(h => h.address === host.address && h.port === host.port);
78
+ if (existingIndex >= 0) {
79
+ const updated = [...prev];
80
+ updated[existingIndex] = host;
81
+ return updated;
82
+ }
83
+ const newHosts = [...prev, host];
84
+ // Auto-select first discovered host if none selected
85
+ setSelectedHostIndex(currentIdx => {
86
+ if (currentIdx === -1 && newHosts.length === 1) {
87
+ return 0;
88
+ }
89
+ return currentIdx;
90
+ });
91
+ return newHosts;
92
+ });
93
+ });
94
+
95
+ // Send initial query
96
+ const initialQueryTimeout = setTimeout(() => {
97
+ listener.sendQuery();
98
+ }, DISCOVERY_INITIAL_DELAY_MS);
99
+
100
+ // Periodically send queries and refresh host list
101
+ const queryInterval = setInterval(() => {
102
+ if (listener.isRunning()) {
103
+ listener.sendQuery();
104
+ // Refresh host list from listener (removes stale hosts)
105
+ const freshHosts = listener.getDiscoveredHosts(DISCOVERY_HOST_MAX_AGE_MS);
106
+ setDiscoveredHosts(freshHosts);
107
+ // Adjust selection if current selection is gone
108
+ setSelectedHostIndex(currentIdx => {
109
+ if (currentIdx >= freshHosts.length) {
110
+ return freshHosts.length > 0 ? freshHosts.length - 1 : -1;
111
+ }
112
+ return currentIdx;
113
+ });
114
+ }
115
+ }, DISCOVERY_QUERY_INTERVAL_MS);
116
+
117
+ return () => {
118
+ clearTimeout(initialQueryTimeout);
119
+ clearInterval(queryInterval);
120
+ listener.stop();
121
+ discoveryRef.current = null;
122
+ setIsScanning(false);
123
+ };
124
+ }, [mode]);
125
+
126
+ // Calculate total fields based on mode
127
+ const getFields = (): string[] => {
128
+ const fields = ['mode', 'nickname'];
129
+ if (mode === 'host') {
130
+ fields.push('port');
131
+ } else {
132
+ // Join mode: show discovered hosts, then manual entry option
133
+ for (let i = 0; i < discoveredHosts.length; i++) {
134
+ fields.push(`host_${i}`);
135
+ }
136
+ fields.push('hostManual'); // Manual entry option
137
+ fields.push('spectate');
138
+ }
139
+ fields.push('password', 'inputDelay', 'start', 'cancel');
140
+ return fields;
141
+ };
142
+
143
+ // Get the effective host address based on selection
144
+ const getEffectiveHostAddress = (): string => {
145
+ if (selectedHostIndex >= 0 && selectedHostIndex < discoveredHosts.length) {
146
+ const host = discoveredHosts[selectedHostIndex];
147
+ return `${host.address}:${host.port}`;
148
+ }
149
+ return hostAddress.trim();
150
+ };
151
+
152
+ const fields = getFields();
153
+ const totalFields = fields.length;
154
+
155
+ // Handle text input for editable fields
156
+ useInput((input, key) => {
157
+ // If editing a text field, handle text input
158
+ if (editingField) {
159
+ if (key.escape || key.return) {
160
+ setEditingField(null);
161
+ return;
162
+ }
163
+ if (key.backspace || key.delete) {
164
+ if (editingField === 'nickname') {
165
+ setNickname(prev => prev.slice(0, -1));
166
+ } else if (editingField === 'hostAddress') {
167
+ setHostAddress(prev => prev.slice(0, -1));
168
+ } else if (editingField === 'password') {
169
+ setPassword(prev => prev.slice(0, -1));
170
+ } else if (editingField === 'port') {
171
+ setPort(prev => Math.floor(prev / DECIMAL_BASE) || 0);
172
+ }
173
+ return;
174
+ }
175
+ // Handle text input
176
+ if (input && !key.ctrl && !key.meta) {
177
+ if (editingField === 'nickname') {
178
+ setNickname(prev => prev + input);
179
+ } else if (editingField === 'hostAddress') {
180
+ setHostAddress(prev => prev + input);
181
+ } else if (editingField === 'password') {
182
+ setPassword(prev => prev + input);
183
+ } else if (editingField === 'port') {
184
+ const num = parseInt(input, DECIMAL_BASE);
185
+ if (!isNaN(num)) {
186
+ setPort(prev => Math.min(PORT_MAX, prev * DECIMAL_BASE + num));
187
+ }
188
+ }
189
+ }
190
+ return;
191
+ }
192
+
193
+ // Navigation
194
+ if (key.escape) {
195
+ onCancel();
196
+ return;
197
+ }
198
+
199
+ if (key.upArrow) {
200
+ setSelectedField(prev => Math.max(0, prev - 1));
201
+ return;
202
+ }
203
+
204
+ if (key.downArrow) {
205
+ setSelectedField(prev => Math.min(totalFields - 1, prev + 1));
206
+ return;
207
+ }
208
+
209
+ const currentField = fields[selectedField];
210
+
211
+ // Left/Right for select fields
212
+ if (key.leftArrow || key.rightArrow) {
213
+ const delta = key.rightArrow ? 1 : -1;
214
+
215
+ if (currentField === 'mode') {
216
+ setMode(prev => prev === 'host' ? 'join' : 'host');
217
+ // Reset field selection when mode changes to avoid invalid index
218
+ setSelectedField(0);
219
+ } else if (currentField === 'spectate') {
220
+ setSpectate(prev => !prev);
221
+ } else if (currentField === 'inputDelay') {
222
+ const currentIdx = inputDelayOptions.findIndex(o => o.value === inputDelay);
223
+ const newIdx = clamp(currentIdx + delta, { min: 0, max: inputDelayOptions.length - 1 });
224
+ setInputDelay(inputDelayOptions[newIdx].value);
225
+ }
226
+ return;
227
+ }
228
+
229
+ // Enter to activate
230
+ if (key.return || input === ' ') {
231
+ if (currentField === 'mode') {
232
+ setMode(prev => prev === 'host' ? 'join' : 'host');
233
+ setSelectedField(0);
234
+ } else if (currentField === 'nickname' || currentField === 'password' || currentField === 'port') {
235
+ setEditingField(currentField);
236
+ } else if (currentField.startsWith('host_')) {
237
+ // Select a discovered host
238
+ const hostIdx = parseInt(currentField.split('_')[1], DECIMAL_BASE);
239
+ setSelectedHostIndex(hostIdx);
240
+ } else if (currentField === 'hostManual') {
241
+ // Switch to manual entry and start editing
242
+ setSelectedHostIndex(-1);
243
+ setEditingField('hostAddress');
244
+ } else if (currentField === 'spectate') {
245
+ setSpectate(prev => !prev);
246
+ } else if (currentField === 'start') {
247
+ // Validate and start
248
+ const effectiveHost = getEffectiveHostAddress();
249
+ if (mode === 'join' && !effectiveHost) {
250
+ // Need host address for join mode
251
+ return;
252
+ }
253
+ onStart({
254
+ mode,
255
+ nickname: nickname.trim() || 'Player',
256
+ port,
257
+ host: mode === 'join' ? effectiveHost : undefined,
258
+ password: password || undefined,
259
+ inputDelay,
260
+ spectate: mode === 'join' ? spectate : undefined,
261
+ });
262
+ } else if (currentField === 'cancel') {
263
+ onCancel();
264
+ }
265
+ }
266
+ });
267
+
268
+ // Gamepad support
269
+ useGamepadContext({
270
+ onUp: () => {
271
+ if (!editingField) {
272
+ setSelectedField(prev => Math.max(0, prev - 1));
273
+ }
274
+ },
275
+ onDown: () => {
276
+ if (!editingField) {
277
+ setSelectedField(prev => Math.min(totalFields - 1, prev + 1));
278
+ }
279
+ },
280
+ onLeft: () => {
281
+ if (!editingField) {
282
+ const currentField = fields[selectedField];
283
+ if (currentField === 'mode') {
284
+ setMode(prev => prev === 'host' ? 'join' : 'host');
285
+ setSelectedField(0);
286
+ } else if (currentField === 'spectate') {
287
+ setSpectate(prev => !prev);
288
+ } else if (currentField === 'inputDelay') {
289
+ const currentIdx = inputDelayOptions.findIndex(o => o.value === inputDelay);
290
+ const newIdx = Math.max(0, currentIdx - 1);
291
+ setInputDelay(inputDelayOptions[newIdx].value);
292
+ }
293
+ }
294
+ },
295
+ onRight: () => {
296
+ if (!editingField) {
297
+ const currentField = fields[selectedField];
298
+ if (currentField === 'mode') {
299
+ setMode(prev => prev === 'host' ? 'join' : 'host');
300
+ setSelectedField(0);
301
+ } else if (currentField === 'spectate') {
302
+ setSpectate(prev => !prev);
303
+ } else if (currentField === 'inputDelay') {
304
+ const currentIdx = inputDelayOptions.findIndex(o => o.value === inputDelay);
305
+ const newIdx = Math.min(inputDelayOptions.length - 1, currentIdx + 1);
306
+ setInputDelay(inputDelayOptions[newIdx].value);
307
+ }
308
+ }
309
+ },
310
+ onConfirm: () => {
311
+ if (editingField) {
312
+ setEditingField(null);
313
+ return;
314
+ }
315
+ const currentField = fields[selectedField];
316
+ if (currentField === 'mode') {
317
+ setMode(prev => prev === 'host' ? 'join' : 'host');
318
+ setSelectedField(0);
319
+ } else if (currentField.startsWith('host_')) {
320
+ // Select a discovered host
321
+ const hostIdx = parseInt(currentField.split('_')[1], DECIMAL_BASE);
322
+ setSelectedHostIndex(hostIdx);
323
+ } else if (currentField === 'hostManual') {
324
+ // Switch to manual entry
325
+ setSelectedHostIndex(-1);
326
+ } else if (currentField === 'spectate') {
327
+ setSpectate(prev => !prev);
328
+ } else if (currentField === 'start') {
329
+ const effectiveHost = getEffectiveHostAddress();
330
+ if (mode === 'join' && !effectiveHost) {
331
+ return;
332
+ }
333
+ onStart({
334
+ mode,
335
+ nickname: nickname.trim() || 'Player',
336
+ port,
337
+ host: mode === 'join' ? effectiveHost : undefined,
338
+ password: password || undefined,
339
+ inputDelay,
340
+ spectate: mode === 'join' ? spectate : undefined,
341
+ });
342
+ } else if (currentField === 'cancel') {
343
+ onCancel();
344
+ }
345
+ },
346
+ onCancel: () => {
347
+ if (editingField) {
348
+ setEditingField(null);
349
+ } else {
350
+ onCancel();
351
+ }
352
+ },
353
+ });
354
+
355
+ if (!ready) {
356
+ return null;
357
+ }
358
+
359
+ const renderField = (fieldId: string, label: string, value: string, isEditing: boolean) => {
360
+ const isSelected = fields[selectedField] === fieldId;
361
+ return (
362
+ <Box key={fieldId}>
363
+ <Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
364
+ {isSelected ? '\u25B6 ' : ' '}{label}:
365
+ </Text>
366
+ <Text> </Text>
367
+ <Text color={isEditing ? 'green' : 'yellow'} bold={isSelected}>
368
+ {isEditing ? `${value}\u2588` : value}
369
+ </Text>
370
+ {isSelected && !isEditing && (
371
+ <Text color="gray" dimColor> (Enter to edit)</Text>
372
+ )}
373
+ </Box>
374
+ );
375
+ };
376
+
377
+ const renderSelect = (fieldId: string, label: string, value: string, _options: string[]) => {
378
+ const isSelected = fields[selectedField] === fieldId;
379
+ return (
380
+ <Box key={fieldId}>
381
+ <Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
382
+ {isSelected ? '\u25B6 ' : ' '}{label}:
383
+ </Text>
384
+ <Text> </Text>
385
+ <Text color="gray">{isSelected ? '\u25C0 ' : ' '}</Text>
386
+ <Text color="yellow" bold={isSelected}>{value}</Text>
387
+ <Text color="gray">{isSelected ? ' \u25B6' : ' '}</Text>
388
+ </Box>
389
+ );
390
+ };
391
+
392
+ const renderToggle = (fieldId: string, label: string, value: boolean) => {
393
+ const isSelected = fields[selectedField] === fieldId;
394
+ return (
395
+ <Box key={fieldId}>
396
+ <Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
397
+ {isSelected ? '\u25B6 ' : ' '}{label}:
398
+ </Text>
399
+ <Text> </Text>
400
+ <Text color={value ? 'green' : 'red'} bold={isSelected}>
401
+ {value ? 'ON' : 'OFF'}
402
+ </Text>
403
+ </Box>
404
+ );
405
+ };
406
+
407
+ const renderButton = (fieldId: string, label: string, color: string = 'cyan') => {
408
+ const isSelected = fields[selectedField] === fieldId;
409
+ return (
410
+ <Box key={fieldId}>
411
+ <Text color={isSelected ? color : 'gray'} bold={isSelected}>
412
+ {isSelected ? '\u25B6 ' : ' '}{label}
413
+ </Text>
414
+ </Box>
415
+ );
416
+ };
417
+
418
+ const renderHostOption = (fieldId: string, host: DiscoveredHost, index: number) => {
419
+ const isSelected = fields[selectedField] === fieldId;
420
+ const isChosen = selectedHostIndex === index;
421
+ const radioIcon = isChosen ? '\u25C9' : '\u25CB'; // ◉ or ○
422
+ return (
423
+ <Box key={fieldId}>
424
+ <Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
425
+ {isSelected ? '\u25B6 ' : ' '}{radioIcon} {host.nickname}
426
+ </Text>
427
+ <Text color="gray"> ({host.address}:{host.port})</Text>
428
+ {host.contentName && (
429
+ <Text color="gray" dimColor> - {host.contentName}</Text>
430
+ )}
431
+ {host.hasPassword && (
432
+ <Text color="yellow"> {'\u{1F512}'}</Text>
433
+ )}
434
+ </Box>
435
+ );
436
+ };
437
+
438
+ const renderManualHostOption = () => {
439
+ const isSelected = fields[selectedField] === 'hostManual';
440
+ const isChosen = selectedHostIndex === -1;
441
+ const radioIcon = isChosen ? '\u25C9' : '\u25CB';
442
+ const isEditing = editingField === 'hostAddress';
443
+ return (
444
+ <Box key="hostManual">
445
+ <Text color={isSelected ? 'cyan' : 'white'} bold={isSelected}>
446
+ {isSelected ? '\u25B6 ' : ' '}{radioIcon} Enter address:
447
+ </Text>
448
+ <Text> </Text>
449
+ <Text color={isEditing ? 'green' : (isChosen ? 'yellow' : 'gray')} bold={isSelected}>
450
+ {isEditing ? `${hostAddress}\u2588` : (hostAddress || '(enter host:port)')}
451
+ </Text>
452
+ {isSelected && !isEditing && (
453
+ <Text color="gray" dimColor> (Enter to edit)</Text>
454
+ )}
455
+ </Box>
456
+ );
457
+ };
458
+
459
+ return (
460
+ <Box flexDirection="column" padding={1}>
461
+ <Box marginBottom={1}>
462
+ <Text bold color="cyan">{'\u{1F310}'} Netplay</Text>
463
+ </Box>
464
+
465
+ <Box marginBottom={1}>
466
+ <Text color="white">Game: </Text>
467
+ <Text color="yellow">{rom.label || rom.metadata.title || rom.filename.replace(/\.[^.]+$/, '')}</Text>
468
+ </Box>
469
+
470
+ <Box flexDirection="column" marginBottom={1}>
471
+ {/* Mode selection */}
472
+ {renderSelect('mode', 'Mode', mode === 'host' ? 'Host Session' : 'Join Session', ['Host Session', 'Join Session'])}
473
+
474
+ {/* Common fields */}
475
+ {renderField('nickname', 'Nickname', nickname, editingField === 'nickname')}
476
+
477
+ {/* Mode-specific fields */}
478
+ {mode === 'host' ? (
479
+ renderField('port', 'Port', String(port), editingField === 'port')
480
+ ) : (
481
+ <>
482
+ {/* Host selection section */}
483
+ <Box marginTop={1} marginBottom={1} flexDirection="column">
484
+ <Box>
485
+ <Text color="white" bold>Select Host</Text>
486
+ {isScanning && (
487
+ <Text color="gray" dimColor> (scanning LAN...)</Text>
488
+ )}
489
+ </Box>
490
+ {discoveredHosts.length === 0 && (
491
+ <Box>
492
+ <Text color="gray" dimColor> No hosts found on local network</Text>
493
+ </Box>
494
+ )}
495
+ {discoveredHosts.map((host, idx) => renderHostOption(`host_${idx}`, host, idx))}
496
+ {renderManualHostOption()}
497
+ </Box>
498
+ {renderToggle('spectate', 'Spectate Only', spectate)}
499
+ </>
500
+ )}
501
+
502
+ {/* Common optional fields */}
503
+ {renderField('password', 'Password', password || '(optional)', editingField === 'password')}
504
+ {renderSelect('inputDelay', 'Input Delay', inputDelayOptions.find(o => o.value === inputDelay)?.label ?? '2', inputDelayOptions.map(o => o.label))}
505
+
506
+ {/* Action buttons */}
507
+ <Box marginTop={1} flexDirection="column">
508
+ {renderButton('start', mode === 'host' ? '\u25B6 Start Hosting' : '\u25B6 Connect', 'green')}
509
+ {renderButton('cancel', '\u2717 Cancel', 'red')}
510
+ </Box>
511
+ </Box>
512
+
513
+ <Box marginTop={1}>
514
+ <Text color="gray" dimColor>
515
+ {'\u2191\u2193'}: Navigate {'\u2190\u2192'}/Space: Change {'\u23CE'}: {editingField ? 'Done' : 'Edit/Activate'} ESC: Cancel
516
+ </Text>
517
+ </Box>
518
+ </Box>
519
+ );
520
+ };