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,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
|
+
};
|