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,2 @@
1
+ /** Delay before showing loading indicator (ms) */
2
+ export const LOADING_INDICATOR_DELAY_MS = 1000;
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Main UI App Component
3
+ *
4
+ * Entry point for the Ink-based terminal UI.
5
+ */
6
+
7
+ import { useState, useEffect, useCallback } from 'react';
8
+ import { Box, Text, render } from 'ink';
9
+ import { Spinner } from '@inkjs/ui';
10
+ import { RomBrowser } from '../RomBrowser';
11
+ import type { RomInfo } from '../../frontend/romScanner';
12
+ import { getPlaylistsDirectory, DEFAULT_CONFIG } from '../../frontend/config';
13
+ import type { Config } from '../../frontend/config';
14
+ import { cleanupInkInstance } from '../../utils/terminal';
15
+ import { getErrorMessage } from '../../utils/getErrorMessage';
16
+ import { GamepadProvider } from '../GamepadContext';
17
+ import { AppCapabilitiesProvider, type AppCapabilities } from '../AppCapabilities';
18
+ import { ConfigProvider } from '../ConfigContext';
19
+ import { AddRomsPrompt } from '../AddRomsPrompt';
20
+ import {
21
+ loadRomsFromPlaylists,
22
+ findPlaylistsInDirectory,
23
+ } from '../../frontend/playlist';
24
+ import type { PlaylistInfo } from '../../frontend/playlist';
25
+ import { detectKittyGraphicsSupport } from '../../utils/kitty';
26
+ import { getWindowManager, isFensterAvailable } from '../../rendering/nativeUi';
27
+ import { logger } from '../../utils/logger';
28
+ import { LOADING_INDICATOR_DELAY_MS } from './consts';
29
+
30
+ export * from './consts';
31
+
32
+
33
+ interface AppProps {
34
+ scanDepth: number; // Max depth for scanning subdirectories (0=only dir, 1=+subdirs, -1=unlimited)
35
+ onRomSelected: (rom: RomInfo, filter: string, resumeGame?: boolean, netplay?: NetplayOptions) => void;
36
+ onExit: (filter: string) => void;
37
+ onRefresh: (filter: string) => void; // Trigger a refresh of the ROM list
38
+ initialSelection?: string; // Path of ROM to select initially
39
+ initialFilter?: string; // Initial search filter to apply
40
+ config: Config; // Current configuration
41
+ configPath?: string; // Path to config file
42
+ showSettingsOnMount?: boolean; // Show settings panel immediately on mount
43
+ lastPlayedRom?: RomInfo; // ROM that was just played (for Resume Game option)
44
+ showNetplayOnMount?: boolean; // Show netplay panel immediately on mount
45
+ kittyGraphicsSupported: boolean; // Whether Kitty graphics protocol is supported
46
+ onScaleFactorChange?: (scaleFactor: number | null) => void; // Callback for native UI scale changes
47
+ }
48
+
49
+ const LoadingState = ({ message = "Scanning for ROMs..." }: { message?: string }) => (
50
+ <Box padding={1}>
51
+ <Spinner label={message} />
52
+ </Box>
53
+ );
54
+
55
+ type AppState = 'loading' | 'prompt' | 'browser' | 'error';
56
+
57
+ const App = ({ scanDepth, onRomSelected, onExit, onRefresh, initialSelection, initialFilter, config, configPath, showSettingsOnMount, lastPlayedRom, showNetplayOnMount, kittyGraphicsSupported, onScaleFactorChange }: AppProps) => {
58
+ const [roms, setRoms] = useState<RomInfo[] | null>(null);
59
+ const [error, setError] = useState<string | null>(null);
60
+ const [appState, setAppState] = useState<AppState>('loading');
61
+ const [showLoadingUI, setShowLoadingUI] = useState(false);
62
+
63
+ // App capabilities for context (avoid prop drilling)
64
+ const capabilities: AppCapabilities = {
65
+ kittyGraphicsSupported,
66
+ nativeSupported: isFensterAvailable(),
67
+ };
68
+
69
+ // Resolve playlist directory from config (uses platform-specific default)
70
+ const playlistDirectory = getPlaylistsDirectory(config);
71
+
72
+ // Delay showing loading indicator to avoid flash for fast loads
73
+ useEffect(() => {
74
+ if (appState !== 'loading') {
75
+ setShowLoadingUI(false);
76
+ return;
77
+ }
78
+
79
+ const timer = setTimeout(() => {
80
+ setShowLoadingUI(true);
81
+ }, LOADING_INDICATOR_DELAY_MS);
82
+
83
+ return () => clearTimeout(timer);
84
+ }, [appState]);
85
+
86
+ useEffect(() => {
87
+ const loadRoms = () => {
88
+ try {
89
+ // Check if there are any playlists
90
+ const playlists = findPlaylistsInDirectory(playlistDirectory);
91
+
92
+ if (playlists.length > 0) {
93
+ // Load all ROMs from playlists
94
+ const playlistRoms = loadRomsFromPlaylists(playlistDirectory, {
95
+ validateFiles: true,
96
+ checkSaveStates: true,
97
+ });
98
+
99
+ setRoms(playlistRoms);
100
+ setAppState('browser');
101
+ } else {
102
+ // No playlists found - show import prompt
103
+ setAppState('prompt');
104
+ }
105
+ } catch (err) {
106
+ setError(getErrorMessage(err));
107
+ setAppState('error');
108
+ }
109
+ };
110
+
111
+ loadRoms();
112
+ }, [playlistDirectory]);
113
+
114
+ // Handle playlist generation (after adding ROMs)
115
+ const handlePlaylistGenerated = useCallback((playlists: PlaylistInfo[]) => {
116
+ if (playlists.length > 0) {
117
+ // Load all ROMs from playlists
118
+ const playlistRoms = loadRomsFromPlaylists(playlistDirectory, {
119
+ validateFiles: true,
120
+ checkSaveStates: true,
121
+ });
122
+
123
+ setRoms(playlistRoms);
124
+ setAppState('browser');
125
+ }
126
+ }, [playlistDirectory]);
127
+
128
+ // Handle exit from prompt
129
+ const handlePromptExit = useCallback(() => {
130
+ onExit('');
131
+ }, [onExit]);
132
+
133
+ if (appState === 'error') {
134
+ return (
135
+ <AppCapabilitiesProvider capabilities={capabilities}>
136
+ <ConfigProvider initialConfig={config} configPath={configPath}>
137
+ <GamepadProvider>
138
+ <Box flexDirection="column" padding={1}>
139
+ <Text color="red">{'\u2717'} Error: {error}</Text>
140
+ <Text color="gray" dimColor>Press any key to exit</Text>
141
+ </Box>
142
+ </GamepadProvider>
143
+ </ConfigProvider>
144
+ </AppCapabilitiesProvider>
145
+ );
146
+ }
147
+
148
+ if (appState === 'loading') {
149
+ // Only show loading indicator after delay to avoid flash for fast loads
150
+ if (!showLoadingUI) {
151
+ return null;
152
+ }
153
+ return (
154
+ <AppCapabilitiesProvider capabilities={capabilities}>
155
+ <ConfigProvider initialConfig={config} configPath={configPath}>
156
+ <GamepadProvider>
157
+ <LoadingState message="Loading game library..." />
158
+ </GamepadProvider>
159
+ </ConfigProvider>
160
+ </AppCapabilitiesProvider>
161
+ );
162
+ }
163
+
164
+ if (appState === 'prompt') {
165
+ return (
166
+ <AppCapabilitiesProvider capabilities={capabilities}>
167
+ <ConfigProvider initialConfig={config} configPath={configPath}>
168
+ <GamepadProvider>
169
+ <AddRomsPrompt
170
+ directory={process.cwd()}
171
+ playlistDirectory={playlistDirectory}
172
+ scanDepth={scanDepth}
173
+ onPlaylistGenerated={handlePlaylistGenerated}
174
+ onExit={handlePromptExit}
175
+ exitAppOnCancel={true}
176
+ />
177
+ </GamepadProvider>
178
+ </ConfigProvider>
179
+ </AppCapabilitiesProvider>
180
+ );
181
+ }
182
+
183
+ return (
184
+ <AppCapabilitiesProvider capabilities={capabilities}>
185
+ <ConfigProvider initialConfig={config} configPath={configPath}>
186
+ <GamepadProvider>
187
+ <RomBrowser
188
+ roms={roms ?? []}
189
+ playlistDirectory={playlistDirectory}
190
+ scanDepth={scanDepth}
191
+ onSelect={onRomSelected}
192
+ onExit={onExit}
193
+ onRefresh={onRefresh}
194
+ initialSelection={initialSelection}
195
+ initialFilter={initialFilter}
196
+ showSettingsOnMount={showSettingsOnMount}
197
+ lastPlayedRom={lastPlayedRom}
198
+ showNetplayOnMount={showNetplayOnMount}
199
+ onScaleFactorChange={onScaleFactorChange}
200
+ />
201
+ </GamepadProvider>
202
+ </ConfigProvider>
203
+ </AppCapabilitiesProvider>
204
+ );
205
+ };
206
+
207
+ /** Netplay options selected from the UI */
208
+ export interface NetplayOptions {
209
+ mode: 'host' | 'join'; // Host a session or join one
210
+ nickname: string; // Player nickname
211
+ port: number; // Port for netplay (default: 55435)
212
+ host?: string; // Host address (only for join mode)
213
+ password?: string; // Optional session password
214
+ inputDelay: number; // Input delay frames (0-16)
215
+ spectate?: boolean; // Join as spectator
216
+ }
217
+
218
+ /** Result from launching the ROM browser */
219
+ export interface BrowserResult {
220
+ path: string | null; // Selected ROM path, or null if cancelled
221
+ filter: string; // Search filter that was active
222
+ shouldRefresh?: boolean; // True if user triggered a refresh (e.g., after adding ROMs)
223
+ resumeGame?: boolean; // True if user selected "Resume Game" from settings
224
+ resumeCoreId?: string; // Core ID to use when resuming (bypasses core selector)
225
+ netplay?: NetplayOptions; // Netplay options if starting a netplay session
226
+ }
227
+
228
+ /**
229
+ * Launch the ROM browser UI.
230
+ * Loads all ROMs from playlists in the configured playlist directory.
231
+ */
232
+ export const launchBrowser = async (scanDepth: number = 1, initialSelection?: string, initialFilter?: string, config?: Config, configPath?: string, showSettingsOnMount?: boolean, lastPlayedRom?: RomInfo, lastPlayedCoreId?: string, showNetplayOnMount?: boolean): Promise<BrowserResult> => {
233
+ // Detect Kitty graphics support before rendering
234
+ // This uses environment variables first (fast), then falls back to protocol query
235
+ const kittyGraphicsSupported = await detectKittyGraphicsSupport();
236
+
237
+ // Use provided config or fall back to defaults
238
+ const effectiveConfig: Config = config ?? DEFAULT_CONFIG;
239
+
240
+ // Check if we should use native mode
241
+ const nativeAvailable = isFensterAvailable();
242
+ const useNativeMode = effectiveConfig.video_driver === 'native' && nativeAvailable;
243
+
244
+ logger.info(
245
+ `Browser mode check: video_driver=${effectiveConfig.video_driver}, native=${nativeAvailable}, useNative=${useNativeMode}`,
246
+ 'Native-UI'
247
+ );
248
+
249
+ // Warn if native mode was requested but the backend is unavailable
250
+ if (effectiveConfig.video_driver === 'native' && !useNativeMode) {
251
+ logger.warn('Native mode requested but the native window backend is unavailable, falling back to terminal', 'Native-UI');
252
+ }
253
+
254
+ const params: BrowserLaunchParams = {
255
+ scanDepth,
256
+ initialSelection,
257
+ initialFilter,
258
+ config: effectiveConfig,
259
+ configPath,
260
+ showSettingsOnMount,
261
+ lastPlayedRom,
262
+ lastPlayedCoreId,
263
+ showNetplayOnMount,
264
+ kittyGraphicsSupported,
265
+ };
266
+
267
+ if (useNativeMode) {
268
+ return launchBrowserNative(params);
269
+ }
270
+
271
+ // Terminal mode (default)
272
+ return launchBrowserTerminal(params);
273
+ };
274
+
275
+ /** Shared parameters for browser launch functions */
276
+ interface BrowserLaunchParams {
277
+ scanDepth: number;
278
+ initialSelection?: string;
279
+ initialFilter?: string;
280
+ config: Config;
281
+ configPath?: string;
282
+ showSettingsOnMount?: boolean;
283
+ lastPlayedRom?: RomInfo;
284
+ lastPlayedCoreId?: string;
285
+ showNetplayOnMount?: boolean;
286
+ kittyGraphicsSupported: boolean;
287
+ }
288
+
289
+ /** Creates the shared handler callbacks and result builder for browser launch */
290
+ const createBrowserHandlers = (params: BrowserLaunchParams) => {
291
+ let selectedPath: string | null = null;
292
+ let currentFilter: string = params.initialFilter ?? '';
293
+ let shouldRefresh = false;
294
+ let isResumeGame = false;
295
+ let resumeCoreId: string | undefined = undefined;
296
+ let netplayOptions: NetplayOptions | undefined = undefined;
297
+
298
+ const handleSelect = (rom: RomInfo, filter: string, resumeGame?: boolean, netplay?: NetplayOptions) => {
299
+ selectedPath = rom.path;
300
+ currentFilter = filter;
301
+ isResumeGame = resumeGame ?? false;
302
+ netplayOptions = netplay;
303
+ if (isResumeGame && params.lastPlayedCoreId) {
304
+ resumeCoreId = params.lastPlayedCoreId;
305
+ }
306
+ };
307
+
308
+ const handleExit = (filter: string) => {
309
+ currentFilter = filter;
310
+ };
311
+
312
+ const handleRefresh = (filter: string) => {
313
+ shouldRefresh = true;
314
+ currentFilter = filter;
315
+ };
316
+
317
+ const getResult = (): BrowserResult => ({
318
+ path: selectedPath,
319
+ filter: currentFilter,
320
+ shouldRefresh,
321
+ resumeGame: isResumeGame,
322
+ resumeCoreId,
323
+ netplay: netplayOptions,
324
+ });
325
+
326
+ return { handleSelect, handleExit, handleRefresh, getResult };
327
+ };
328
+
329
+ /**
330
+ * Launch browser in terminal mode (default)
331
+ */
332
+ const launchBrowserTerminal = (params: BrowserLaunchParams): Promise<BrowserResult> => new Promise((resolve) => {
333
+ const { handleSelect, handleExit, handleRefresh, getResult } = createBrowserHandlers(params);
334
+
335
+ const instance = render(
336
+ <App
337
+ scanDepth={params.scanDepth}
338
+ onRomSelected={handleSelect}
339
+ onExit={handleExit}
340
+ onRefresh={handleRefresh}
341
+ initialSelection={params.initialSelection}
342
+ initialFilter={params.initialFilter}
343
+ config={params.config}
344
+ configPath={params.configPath}
345
+ showSettingsOnMount={params.showSettingsOnMount}
346
+ lastPlayedRom={params.lastPlayedRom}
347
+ showNetplayOnMount={params.showNetplayOnMount}
348
+ kittyGraphicsSupported={params.kittyGraphicsSupported}
349
+ />,
350
+ { exitOnCtrlC: false }
351
+ );
352
+
353
+ void instance.waitUntilExit().then(() => {
354
+ cleanupInkInstance(instance, resolve, getResult());
355
+ });
356
+ });
357
+
358
+ /**
359
+ * Launch browser in native window mode
360
+ * Creates/reuses the shared native window and routes Ink rendering through ink-native streams
361
+ */
362
+ const launchBrowserNative = (params: BrowserLaunchParams): Promise<BrowserResult> => new Promise((resolve) => {
363
+ const { handleSelect, handleExit, handleRefresh, getResult } = createBrowserHandlers(params);
364
+ const windowManager = getWindowManager();
365
+ try {
366
+ if (!windowManager.isInitialized()) {
367
+ windowManager.init({ title: 'emoemu - Game Library', scaleFactor: params.config.menu_scale_factor });
368
+ }
369
+ windowManager.setMode('ui');
370
+
371
+ const stdin = windowManager.getStdin();
372
+ const stdout = windowManager.getStdout();
373
+ const window = windowManager.getWindow();
374
+ windowManager.clearScreen();
375
+
376
+ const onClose = () => { stdin.push('\x1b'); };
377
+ window.on('close', onClose);
378
+ logger.info('Native UI mode enabled for browser (shared window)', 'Native-UI');
379
+
380
+ // NOTE: ink-native has no runtime setScaleFactor. menu_scale_factor is applied
381
+ // at window creation; live changes take effect on next launch.
382
+ const handleScaleFactorChange = (_scaleFactor: number | null) => {
383
+ // Persisted to config by the settings panel; re-render UI to reflect other changes.
384
+ stdout.emit('resize');
385
+ };
386
+
387
+ const instance = render(
388
+ <App
389
+ scanDepth={params.scanDepth}
390
+ onRomSelected={handleSelect}
391
+ onExit={handleExit}
392
+ onRefresh={handleRefresh}
393
+ initialSelection={params.initialSelection}
394
+ initialFilter={params.initialFilter}
395
+ config={params.config}
396
+ configPath={params.configPath}
397
+ showSettingsOnMount={params.showSettingsOnMount}
398
+ lastPlayedRom={params.lastPlayedRom}
399
+ showNetplayOnMount={params.showNetplayOnMount}
400
+ kittyGraphicsSupported={params.kittyGraphicsSupported}
401
+ onScaleFactorChange={handleScaleFactorChange}
402
+ />,
403
+ {
404
+ exitOnCtrlC: false,
405
+ stdout: stdout as unknown as NodeJS.WriteStream,
406
+ stdin: stdin as unknown as NodeJS.ReadStream,
407
+ }
408
+ );
409
+
410
+ void instance.waitUntilExit().then(() => {
411
+ // Do NOT close the shared window here — only detach this screen's listener.
412
+ window.off('close', onClose);
413
+ windowManager.getRenderer().reset();
414
+ resolve(getResult());
415
+ });
416
+ } catch (error) {
417
+ logger.warn(`Native UI failed, falling back to terminal: ${error}`, 'Native-UI');
418
+ void launchBrowserTerminal(params).then(resolve);
419
+ }
420
+ });
421
+
422
+ export default App;
423
+
424
+ /**
425
+ * Import ROMs from a directory with progress UI.
426
+ * Shows a progress bar during scanning, then auto-continues when done.
427
+ */
428
+ export const importDirectory = async (directory: string, scanDepth: number, config: Config): Promise<void> => new Promise((resolve) => {
429
+ const playlistDirectory = getPlaylistsDirectory(config);
430
+
431
+ const handleComplete = () => {
432
+ // Import complete - resolve immediately
433
+ };
434
+
435
+ const handleExit = () => {
436
+ // User cancelled - still resolve (they can try again later)
437
+ };
438
+
439
+ const instance = render(
440
+ <GamepadProvider>
441
+ <AddRomsPrompt
442
+ directory={directory}
443
+ playlistDirectory={playlistDirectory}
444
+ scanDepth={scanDepth}
445
+ onPlaylistGenerated={handleComplete}
446
+ onExit={handleExit}
447
+ autoImport={true}
448
+ />
449
+ </GamepadProvider>
450
+ );
451
+
452
+ // Wait for the UI to exit, then clean up
453
+ void instance.waitUntilExit().then(() => {
454
+ cleanupInkInstance(instance, resolve, undefined);
455
+ });
456
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * App Capabilities Context
3
+ *
4
+ * Provides app-wide information about terminal and system capabilities.
5
+ * This avoids prop drilling for commonly needed capability flags.
6
+ */
7
+
8
+ import { createContext, useContext, type ReactNode } from 'react';
9
+
10
+ /**
11
+ * App capabilities that are detected at startup and available throughout the app.
12
+ */
13
+ export interface AppCapabilities {
14
+ /** Whether the terminal supports Kitty graphics protocol */
15
+ kittyGraphicsSupported: boolean;
16
+ /** Whether the native window backend is available for rendering */
17
+ nativeSupported: boolean;
18
+ }
19
+
20
+ const AppCapabilitiesContext = createContext<AppCapabilities | null>(null);
21
+
22
+ interface AppCapabilitiesProviderProps {
23
+ children: ReactNode;
24
+ capabilities: AppCapabilities;
25
+ }
26
+
27
+ /**
28
+ * Provider for app-wide capabilities.
29
+ * Should wrap the entire app (or UI tree) to make capabilities available.
30
+ */
31
+ export const AppCapabilitiesProvider = ({ children, capabilities }: AppCapabilitiesProviderProps) => {
32
+ return (
33
+ <AppCapabilitiesContext.Provider value={capabilities}>
34
+ {children}
35
+ </AppCapabilitiesContext.Provider>
36
+ );
37
+ };
38
+
39
+ /**
40
+ * Hook to access app capabilities.
41
+ * Must be used within an AppCapabilitiesProvider.
42
+ *
43
+ * @throws Error if used outside of AppCapabilitiesProvider
44
+ */
45
+ export const useAppCapabilities = (): AppCapabilities => {
46
+ const context = useContext(AppCapabilitiesContext);
47
+ if (!context) {
48
+ throw new Error('useAppCapabilities must be used within an AppCapabilitiesProvider');
49
+ }
50
+ return context;
51
+ };
52
+
53
+ /**
54
+ * Hook to check if Kitty graphics protocol is supported.
55
+ * Convenience wrapper around useAppCapabilities.
56
+ */
57
+ export const useKittyGraphicsSupported = (): boolean => {
58
+ return useAppCapabilities().kittyGraphicsSupported;
59
+ };
60
+
61
+ /**
62
+ * Hook to check if the native window backend is supported.
63
+ * Convenience wrapper around useAppCapabilities.
64
+ */
65
+ export const useNativeSupported = (): boolean => {
66
+ return useAppCapabilities().nativeSupported;
67
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Config Context
3
+ *
4
+ * Provides app-wide config state and config file path.
5
+ * This avoids prop drilling config through RomBrowser and SettingsPanel.
6
+ */
7
+
8
+ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
9
+ import type { Config } from '../../frontend/config';
10
+ import { loadConfig } from '../../frontend/config';
11
+
12
+ interface ConfigContextValue {
13
+ config: Config;
14
+ configPath?: string;
15
+ setConfig: (config: Config) => void;
16
+ reloadConfig: () => void;
17
+ }
18
+
19
+ const ConfigContext = createContext<ConfigContextValue | null>(null);
20
+
21
+ interface ConfigProviderProps {
22
+ children: ReactNode;
23
+ initialConfig: Config;
24
+ configPath?: string;
25
+ }
26
+
27
+ /**
28
+ * Provider for app-wide config state.
29
+ * Should wrap the UI tree to make config available via useConfig().
30
+ */
31
+ export const ConfigProvider = ({ children, initialConfig, configPath }: ConfigProviderProps) => {
32
+ const [config, setConfig] = useState(initialConfig);
33
+
34
+ const reloadConfig = useCallback(() => {
35
+ const { config: reloaded } = loadConfig(configPath);
36
+ setConfig(reloaded);
37
+ }, [configPath]);
38
+
39
+ return (
40
+ <ConfigContext.Provider value={{ config, configPath, setConfig, reloadConfig }}>
41
+ {children}
42
+ </ConfigContext.Provider>
43
+ );
44
+ };
45
+
46
+ /**
47
+ * Hook to access config context.
48
+ * Must be used within a ConfigProvider.
49
+ */
50
+ export const useConfig = (): ConfigContextValue => {
51
+ const context = useContext(ConfigContext);
52
+ if (!context) {
53
+ throw new Error('useConfig must be used within a ConfigProvider');
54
+ }
55
+ return context;
56
+ };
@@ -0,0 +1,11 @@
1
+ /** Tab indices */
2
+ export const TAB_INSTALLED = 0;
3
+ export const TAB_DOWNLOAD = 1;
4
+
5
+ /** UI layout constants */
6
+ export const PANEL_PADDING = 1;
7
+ export const LIST_ITEM_HEIGHT = 1;
8
+ export const MAX_VISIBLE_ITEMS = 15;
9
+
10
+ /** Bytes per kilobyte for display formatting */
11
+ export const BYTES_PER_KB = 1024;