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,1164 @@
1
+ /**
2
+ * ROM Browser UI Component
3
+ *
4
+ * A beautiful terminal UI for browsing and selecting ROMs.
5
+ */
6
+
7
+ import { useState, useEffect, useMemo, useCallback, useRef, memo } from 'react';
8
+ import { Box, Text, useInput, useApp, useStdin, useStdout } from 'ink';
9
+ import { filter } from 'remeda';
10
+ import type { RomInfo, ThumbnailResult } from '../../frontend/romScanner';
11
+ import { loadAnyThumbnail } from '../../frontend/romScanner';
12
+ import type { SaveStateDetails } from '../../frontend/saveServices';
13
+ import { getSaveStateService } from '../../frontend/serviceProvider';
14
+ import { buildKittyImageSequence, buildKittyDeleteSequence, buildCursorPositionSequence } from '../../utils/kitty';
15
+ import {
16
+ renderThumbnailHalfBlocks,
17
+ buildThumbnailSequence,
18
+ buildThumbnailClearSequence,
19
+ type RenderedThumbnail,
20
+ } from '../../utils/thumbnailRenderer';
21
+ import { updateLastPlayed } from '../../frontend/playlist';
22
+ import { AddRomsPrompt } from '../AddRomsPrompt';
23
+ import { CoreManager } from '../CoreManager';
24
+ import { getSupportedExtensions } from '../../frontend/coreRegistry';
25
+ import { formatRuntimeSeconds } from '../../utils/format';
26
+ import { useGamepadContext } from '../GamepadContext';
27
+ import { useKittyGraphicsSupported } from '../AppCapabilities';
28
+ import { useConfig } from '../ConfigContext';
29
+ import {
30
+ DEFAULT_TERM_WIDTH,
31
+ DEFAULT_TERM_HEIGHT,
32
+ } from '..';
33
+ import {
34
+ THUMBNAIL_LOAD_DELAY_MS,
35
+ THUMBNAIL_KITTY_IMAGE_ID,
36
+ THUMBNAIL_DISPLAY_COLS,
37
+ THUMBNAIL_DISPLAY_ROWS,
38
+ ROM_MIN_DISPLAY_WIDTH,
39
+ ROM_LIST_HEADER_ROWS,
40
+ ROM_META_LABEL_WIDTH,
41
+ ROM_PANEL_BORDER_PADDING,
42
+ ROM_SEPARATOR_MAX_WIDTH,
43
+ ROM_SEPARATOR_PADDING,
44
+ ROM_OPTIONS_PANEL_ROWS,
45
+ PERCENT_60,
46
+ PERCENT_40,
47
+ SEARCH_DEBOUNCE_MS,
48
+ MOUSE_BUFFER_MAX_SIZE,
49
+ } from './consts';
50
+ import type { RomBrowserProps, ActionButtonDef, MetadataPanelProps } from './types';
51
+ import { NetplayPanel } from './NetplayPanel';
52
+ import { SettingsPanel } from './SettingsPanel';
53
+
54
+ export * from './types';
55
+ export * from './consts';
56
+
57
+ // Mouse tracking escape sequences (SGR mode 1006 for extended coordinates)
58
+ const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1006h';
59
+ const DISABLE_MOUSE = '\x1b[?1000l\x1b[?1006l';
60
+
61
+ const actionButtons: ActionButtonDef[] = [
62
+ { id: 'add-roms', label: 'Add ROMs', icon: '\u{1F4C2}' },
63
+ { id: 'manage-cores', label: 'Manage Cores', icon: '\u{1F9E9}' }, // Puzzle piece
64
+ { id: 'netplay', label: 'Netplay', icon: '\u{1F310}' }, // Globe icon
65
+ { id: 'settings', label: 'Settings', icon: '\u2699' },
66
+ ];
67
+
68
+ // Action button component
69
+ const ActionButton = ({ button, isSelected, isFocused, highlightBg, highlightFg }: {
70
+ button: ActionButtonDef;
71
+ isSelected: boolean;
72
+ isFocused: boolean;
73
+ highlightBg: string;
74
+ highlightFg: string;
75
+ }) => {
76
+ const showHighlight = isSelected && isFocused;
77
+ return (
78
+ <Box marginRight={1}>
79
+ <Text
80
+ backgroundColor={showHighlight ? highlightBg : undefined}
81
+ color={showHighlight ? highlightFg : isSelected ? 'cyan' : 'gray'}
82
+ bold={showHighlight}
83
+ >
84
+ {' '}{button.icon} {button.label}{' '}
85
+ </Text>
86
+ </Box>
87
+ );
88
+ };
89
+
90
+ // Color schemes for different systems
91
+ const systemColors: Record<string, string> = {
92
+ 'Nintendo Entertainment System': 'red',
93
+ 'Game Boy': 'green',
94
+ 'Game Boy Color': 'magenta',
95
+ 'Super Nintendo': 'blue',
96
+ 'Sega Genesis': 'cyan',
97
+ 'Sega Master System': 'cyan',
98
+ 'Sega Game Gear': 'cyan',
99
+ 'Game Boy Advance': 'magenta',
100
+ 'PC Engine': 'yellow',
101
+ };
102
+
103
+ const getSystemColor = (system: string): string => systemColors[system] ?? 'white';
104
+
105
+ // Truncate string to fit width
106
+ const truncate = (str: string, maxLength: number): string => {
107
+ if (str.length <= maxLength) {return str;}
108
+ return str.slice(0, maxLength - 1) + '\u2026';
109
+ };
110
+
111
+ /**
112
+ * Filter ROMs by case-insensitive substring match on filename, title, or system
113
+ */
114
+ const filterRoms = (roms: RomInfo[], query: string): RomInfo[] => {
115
+ const trimmed = query.trim();
116
+ if (!trimmed) {return roms;}
117
+
118
+ const lowerQuery = trimmed.toLowerCase();
119
+
120
+ const matchesQuery = (text: string | undefined): boolean =>
121
+ text !== undefined && text.toLowerCase().includes(lowerQuery);
122
+
123
+ return filter(roms, (rom) =>
124
+ matchesQuery(rom.label) ||
125
+ matchesQuery(rom.filename) ||
126
+ matchesQuery(rom.metadata.title) ||
127
+ matchesQuery(rom.system)
128
+ );
129
+ };
130
+
131
+ // ROM list item component
132
+ const RomListItem = ({ rom, isSelected, width, highlightBg, highlightFg }: {
133
+ rom: RomInfo;
134
+ isSelected: boolean;
135
+ width: number;
136
+ highlightBg: string;
137
+ highlightFg: string;
138
+ }) => {
139
+ const color = getSystemColor(rom.system);
140
+
141
+ // Calculate available space for ROM name
142
+ // Format: " [save] name"
143
+ // Show disk emoji for save state, battery emoji for battery save only, nothing for no save
144
+ const saveIndicator = rom.hasSaveState ? '\u{1F4BE} ' : (rom.hasBatterySave ? '\u{1F50B} ' : ' ');
145
+ const SELECTION_PREFIX_WIDTH = 2; // " " or "> "
146
+ const prefixWidth = SELECTION_PREFIX_WIDTH + saveIndicator.length;
147
+ const availableWidth = Math.max(ROM_MIN_DISPLAY_WIDTH, width - prefixWidth);
148
+
149
+ // Prefer playlist label, fall back to filename without extension
150
+ const romName = rom.label ?? rom.filename.replace(/\.[^.]+$/, '');
151
+ const displayName = truncate(romName, availableWidth);
152
+
153
+ return (
154
+ <Box>
155
+ <Text
156
+ backgroundColor={isSelected ? highlightBg : undefined}
157
+ color={isSelected ? highlightFg : color}
158
+ bold={isSelected}
159
+ >
160
+ {isSelected ? '\u25B6 ' : ' '}
161
+ {saveIndicator}{displayName}
162
+ </Text>
163
+ </Box>
164
+ );
165
+ };
166
+
167
+ // Metadata panel component - memoized to prevent unnecessary re-renders
168
+ const MetadataPanel = memo(({
169
+ rom,
170
+ width,
171
+ height,
172
+ saveStateDetails,
173
+ thumbnail,
174
+ isKittySupported,
175
+ panelStartCol,
176
+ }: MetadataPanelProps) => {
177
+ const color = rom ? getSystemColor(rom.system) : 'gray';
178
+
179
+ // Build metadata lines (computed early so we can use line count for thumbnail positioning)
180
+ const lines: Array<{ label: string; value: string; color?: string }> = [];
181
+ if (rom) {
182
+ const meta = rom.metadata;
183
+ // Title is always shown - prefer playlist label, then ROM header title, then filename as fallback
184
+ const title = rom.label || meta.title || rom.filename.replace(/\.[^.]+$/, '');
185
+ lines.push({ label: 'Title', value: title });
186
+ lines.push({ label: 'System', value: rom.system, color });
187
+
188
+ // Publisher - trim and ignore if starts with "." or ends with ".xxx" or ",xxx" (indicates unavailable)
189
+ const publisher = meta.publisher?.trim();
190
+ const hasInvalidPublisher = !publisher || publisher.startsWith('.') || /[.,][a-zA-Z]{3}$/.test(publisher);
191
+ if (!hasInvalidPublisher) {
192
+ lines.push({ label: 'Publisher', value: publisher });
193
+ }
194
+
195
+ // Playtime (from playlist runtime data)
196
+ if (rom.runtimeSeconds !== undefined && rom.runtimeSeconds > 0) {
197
+ lines.push({ label: 'Playtime', value: `\u{23F1} ${formatRuntimeSeconds(rom.runtimeSeconds)}`, color: 'cyan' });
198
+ }
199
+
200
+ // Last Played - from playlist data
201
+ if (rom.lastPlayed) {
202
+ const formattedDate = rom.lastPlayed.toLocaleDateString();
203
+ const formattedTime = rom.lastPlayed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
204
+ lines.push({ label: 'Last Played', value: `\u{1F4C5} ${formattedDate} ${formattedTime}`, color: 'cyan' });
205
+ }
206
+
207
+ // Battery save info (.srm file) - only show if no save state exists
208
+ if (rom.hasBatterySave && !rom.hasSaveState) {
209
+ if (rom.batterySaveDate) {
210
+ const batteryDate = rom.batterySaveDate.toLocaleDateString();
211
+ const batteryTime = rom.batterySaveDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
212
+ lines.push({ label: 'Battery Save', value: `\u{1F50B} ${batteryDate} ${batteryTime}`, color: 'green' });
213
+ } else {
214
+ lines.push({ label: 'Battery Save', value: '\u{1F50B} Unknown date', color: 'green' });
215
+ }
216
+ }
217
+
218
+ // Save state info (use lazy-loaded savedAt from file contents)
219
+ if (rom.hasSaveState) {
220
+ const savedAt = saveStateDetails?.savedAt;
221
+ if (savedAt) {
222
+ const stateDate = savedAt.toLocaleDateString();
223
+ const stateTime = savedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
224
+ lines.push({ label: 'Save State', value: `\u{1F4BE} ${stateDate} ${stateTime}`, color: 'cyan' });
225
+ } else {
226
+ lines.push({ label: 'Save State', value: '\u{1F4BE}', color: 'cyan' });
227
+ }
228
+ }
229
+
230
+ }
231
+
232
+ // Render thumbnail using Kitty graphics protocol
233
+ useEffect(() => {
234
+ if (!thumbnail || !isKittySupported) {
235
+ return;
236
+ }
237
+
238
+ // Calculate position for thumbnail (after metadata lines)
239
+ // Row calculation:
240
+ // - 4 rows for header: panel border top (1) + "ROM Details" header (1) + marginBottom gap (1) + first line offset (1)
241
+ // - lines.length rows for metadata
242
+ // - 4 rows for gap after metadata
243
+ const HEADER_ROWS = 4;
244
+ const GAP_ROWS = 4;
245
+ const thumbnailRow = HEADER_ROWS + lines.length + GAP_ROWS;
246
+
247
+ // Align thumbnail with the values column (after the label width)
248
+ // Column offset: panelStartCol + paddingX (2) + ROM_META_LABEL_WIDTH (12)
249
+ const thumbnailCol = panelStartCol + 2 + ROM_META_LABEL_WIDTH;
250
+
251
+ // Build and write the image sequence
252
+ const positionSeq = buildCursorPositionSequence(thumbnailRow, thumbnailCol);
253
+ const imageSeq = buildKittyImageSequence(
254
+ thumbnail,
255
+ THUMBNAIL_DISPLAY_COLS,
256
+ THUMBNAIL_DISPLAY_ROWS,
257
+ THUMBNAIL_KITTY_IMAGE_ID
258
+ );
259
+
260
+ process.stdout.write(positionSeq + imageSeq);
261
+
262
+ // Cleanup: delete the image when thumbnail changes or component unmounts
263
+ return () => {
264
+ const deleteSeq = buildKittyDeleteSequence(THUMBNAIL_KITTY_IMAGE_ID);
265
+ process.stdout.write(deleteSeq);
266
+ };
267
+ }, [thumbnail, isKittySupported, panelStartCol, lines.length]);
268
+
269
+ // Render thumbnail using half-block characters (fallback when Kitty not supported)
270
+ const [halfBlockThumbnail, setHalfBlockThumbnail] = useState<RenderedThumbnail | undefined>();
271
+
272
+ useEffect(() => {
273
+ if (!thumbnail || isKittySupported) {
274
+ setHalfBlockThumbnail(undefined);
275
+ return;
276
+ }
277
+
278
+ // Decode and render the thumbnail asynchronously
279
+ let cancelled = false;
280
+ renderThumbnailHalfBlocks(thumbnail, THUMBNAIL_DISPLAY_COLS, THUMBNAIL_DISPLAY_ROWS)
281
+ .then((rendered) => {
282
+ if (!cancelled && rendered) {
283
+ setHalfBlockThumbnail(rendered);
284
+ }
285
+ })
286
+ .catch(() => {
287
+ // Silently ignore errors
288
+ });
289
+
290
+ return () => {
291
+ cancelled = true;
292
+ };
293
+ }, [thumbnail, isKittySupported]);
294
+
295
+ // Write half-block thumbnail to terminal
296
+ useEffect(() => {
297
+ if (!halfBlockThumbnail || isKittySupported) {
298
+ return;
299
+ }
300
+
301
+ // Calculate position (same as Kitty thumbnail)
302
+ const HEADER_ROWS = 4;
303
+ const GAP_ROWS = 4;
304
+ const thumbnailRow = HEADER_ROWS + lines.length + GAP_ROWS;
305
+ const thumbnailCol = panelStartCol + 2 + ROM_META_LABEL_WIDTH;
306
+
307
+ // Render the half-block thumbnail
308
+ const seq = buildThumbnailSequence(halfBlockThumbnail, thumbnailRow, thumbnailCol);
309
+ process.stdout.write(seq);
310
+
311
+ // Cleanup: clear the thumbnail area
312
+ return () => {
313
+ const clearSeq = buildThumbnailClearSequence(
314
+ halfBlockThumbnail.width,
315
+ halfBlockThumbnail.height,
316
+ thumbnailRow,
317
+ thumbnailCol
318
+ );
319
+ process.stdout.write(clearSeq);
320
+ };
321
+ }, [halfBlockThumbnail, isKittySupported, panelStartCol, lines.length]);
322
+
323
+ if (!rom) {
324
+ return (
325
+ <Box
326
+ flexDirection="column"
327
+ width={width}
328
+ height={height}
329
+ borderStyle="round"
330
+ borderColor="gray"
331
+ paddingX={1}
332
+ >
333
+ <Text color="gray" italic>No ROM selected</Text>
334
+ </Box>
335
+ );
336
+ }
337
+
338
+ // Calculate available lines for metadata (account for header, footer, borders)
339
+ // Reserve space for thumbnail (Kitty or half-block)
340
+ const hasThumbnailToShow = thumbnail && (isKittySupported || halfBlockThumbnail);
341
+ const thumbnailRowsReserved = hasThumbnailToShow ? THUMBNAIL_DISPLAY_ROWS + 2 : 0;
342
+ const availableLines = height - ROM_LIST_HEADER_ROWS - thumbnailRowsReserved;
343
+
344
+ return (
345
+ <Box
346
+ flexDirection="column"
347
+ width={width}
348
+ height={height}
349
+ borderStyle="round"
350
+ borderColor={color}
351
+ paddingX={1}
352
+ >
353
+ <Box marginBottom={1}>
354
+ <Text bold color={color}>ROM Details</Text>
355
+ </Box>
356
+
357
+ {lines.slice(0, availableLines).map((line, i) => (
358
+ <Box key={i}>
359
+ <Text color="gray">{line.label.padEnd(ROM_META_LABEL_WIDTH)}</Text>
360
+ <Text color={line.color ?? 'white'}>{truncate(line.value, width - ROM_PANEL_BORDER_PADDING)}</Text>
361
+ </Box>
362
+ ))}
363
+
364
+ {/* Spacer for thumbnail area (Kitty or half-block) */}
365
+ {hasThumbnailToShow && (
366
+ <Box height={THUMBNAIL_DISPLAY_ROWS + 1} />
367
+ )}
368
+
369
+ <Box marginTop={1} flexGrow={1} />
370
+
371
+ <Box borderStyle="single" borderColor="gray" borderTop borderBottom={false} borderLeft={false} borderRight={false}>
372
+ <Text color="gray" dimColor>
373
+ {'\u23CE'} Play {'\u2191\u2193'} Navigate
374
+ </Text>
375
+ </Box>
376
+ </Box>
377
+ );
378
+ });
379
+
380
+ // Header component
381
+ const Header = ({ romCount, totalCount, searchQuery }: { romCount: number; totalCount: number; searchQuery: string }) => {
382
+ const { stdout } = useStdout();
383
+ const separatorWidth = Math.min(ROM_SEPARATOR_MAX_WIDTH, (stdout.columns || DEFAULT_TERM_WIDTH) - ROM_SEPARATOR_PADDING + 1);
384
+
385
+ return (
386
+ <Box flexDirection="column" marginBottom={1}>
387
+ <Box>
388
+ <Text bold color="cyan">
389
+ {'\u{1F3AE}'} emoemu
390
+ </Text>
391
+ <Text color="gray"> - </Text>
392
+ {searchQuery ? (
393
+ <>
394
+ <Text color="yellow">{romCount.toLocaleString()}</Text>
395
+ <Text color="gray">/{totalCount.toLocaleString()} matching "</Text>
396
+ <Text color="yellow">{searchQuery}</Text>
397
+ <Text color="gray">"</Text>
398
+ </>
399
+ ) : (
400
+ <Text color="white">{romCount.toLocaleString()} {romCount === 1 ? 'ROM' : 'ROMs'}</Text>
401
+ )}
402
+ </Box>
403
+ {searchQuery ? (
404
+ <Box>
405
+ <Text color="gray" dimColor>
406
+ {'\u{1F50D}'} Filter: </Text>
407
+ <Text color="yellow" bold>{searchQuery}</Text>
408
+ <Text color="gray" dimColor> (ESC to clear)</Text>
409
+ </Box>
410
+ ) : (
411
+ <Box>
412
+ <Text color="gray" dimColor>
413
+ {'━'.repeat(separatorWidth)}
414
+ </Text>
415
+ </Box>
416
+ )}
417
+ </Box>
418
+ );
419
+ };
420
+
421
+ // Empty state component
422
+ const EmptyState = () => {
423
+ const extensions = getSupportedExtensions();
424
+ const extensionList = extensions.length > 0
425
+ ? extensions.join(', ')
426
+ : 'No cores loaded';
427
+
428
+ return (
429
+ <Box flexDirection="column" padding={2}>
430
+ <Text color="yellow">{'\u26A0'} No ROMs found in this directory</Text>
431
+ <Box marginTop={1}>
432
+ <Text color="gray">Supported formats ({extensions.length}):</Text>
433
+ </Box>
434
+ <Text color="white">{extensionList}</Text>
435
+ </Box>
436
+ );
437
+ };
438
+
439
+ // Main browser component
440
+ export const RomBrowser = ({ roms, playlistDirectory, scanDepth, onSelect, onExit: _onExit, onRefresh, initialSelection, initialFilter, showSettingsOnMount, lastPlayedRom, showNetplayOnMount, onScaleFactorChange }: RomBrowserProps) => {
441
+ const { exit } = useApp();
442
+ const { stdout } = useStdout();
443
+ const { config: localConfig, reloadConfig } = useConfig();
444
+
445
+ // Track previous scale factor to detect changes
446
+ const prevScaleFactorRef = useRef(localConfig.menu_scale_factor);
447
+
448
+ // Call onScaleFactorChange when menu_scale_factor changes
449
+ useEffect(() => {
450
+ const currentScale = localConfig.menu_scale_factor;
451
+ const prevScale = prevScaleFactorRef.current;
452
+
453
+ // Check if scale factor actually changed (handle null comparison)
454
+ const changed = currentScale !== prevScale;
455
+ if (changed && onScaleFactorChange) {
456
+ onScaleFactorChange(currentScale);
457
+ }
458
+
459
+ prevScaleFactorRef.current = currentScale;
460
+ }, [localConfig.menu_scale_factor, onScaleFactorChange]);
461
+
462
+ // Initialize search query with initial filter
463
+ // searchQuery is for immediate display, debouncedSearchQuery is for filtering
464
+ const [searchQuery, setSearchQuery] = useState(initialFilter ?? '');
465
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(initialFilter ?? '');
466
+
467
+ // Debounce search query updates to avoid filtering on every keystroke
468
+ useEffect(() => {
469
+ const timer = setTimeout(() => {
470
+ setDebouncedSearchQuery(searchQuery);
471
+ }, SEARCH_DEBOUNCE_MS);
472
+ return () => clearTimeout(timer);
473
+ }, [searchQuery]);
474
+
475
+ // Compute initial filtered list for finding initial selection
476
+ const initialFilteredRoms = initialFilter ? filterRoms(roms, initialFilter) : roms;
477
+
478
+ const [selectedIndex, setSelectedIndex] = useState(() => {
479
+ // Find initial selection index in the FILTERED list
480
+ if (initialSelection) {
481
+ const index = initialFilteredRoms.findIndex(rom => rom.path === initialSelection);
482
+ if (index !== -1) {return index;}
483
+ }
484
+ return 0;
485
+ });
486
+ const [scrollOffset, setScrollOffset] = useState(() => {
487
+ // Calculate initial scroll offset to show selected item
488
+ if (initialSelection) {
489
+ const index = initialFilteredRoms.findIndex(rom => rom.path === initialSelection);
490
+ if (index !== -1) {
491
+ const termHeight = process.stdout.rows || DEFAULT_TERM_HEIGHT;
492
+ const listHeight = termHeight - ROM_OPTIONS_PANEL_ROWS;
493
+ const MIN_VISIBLE = 1;
494
+ const VISIBLE_PADDING = 2;
495
+ const visibleItems = Math.max(MIN_VISIBLE, listHeight - VISIBLE_PADDING);
496
+ // Center the selection if possible
497
+ return Math.max(0, index - Math.floor(visibleItems / 2));
498
+ }
499
+ }
500
+ return 0;
501
+ });
502
+
503
+ // Force re-render on mount to trigger Ink's terminal setup before user interaction
504
+ const [, setMountRender] = useState(0);
505
+ useEffect(() => {
506
+ // Trigger a re-render after mount to ensure proper layout
507
+ setMountRender(1);
508
+ }, []);
509
+
510
+ // Action buttons state
511
+ const [actionButtonsFocused, setActionButtonsFocused] = useState(false);
512
+ const [actionButtonIndex, setActionButtonIndex] = useState(0);
513
+
514
+ // Auto-focus action buttons when no ROMs are found (so user can press Enter to add ROMs)
515
+ useEffect(() => {
516
+ if (roms.length === 0) {
517
+ setActionButtonsFocused(true);
518
+ setActionButtonIndex(0); // Focus "Add ROMs" button
519
+ }
520
+ }, [roms.length]);
521
+
522
+ // Add ROMs prompt state
523
+ const [showAddRomsPrompt, setShowAddRomsPrompt] = useState(false);
524
+
525
+ // Settings panel state - open on mount if coming from a game
526
+ const [showSettings, setShowSettings] = useState(showSettingsOnMount ?? false);
527
+ // Track resumable ROM locally - cleared when settings is closed (Resume Game only shows once after exiting a game)
528
+ const [resumableRom, setResumableRom] = useState<RomInfo | undefined>(lastPlayedRom);
529
+
530
+ // Netplay panel state
531
+ const [showNetplay, setShowNetplay] = useState(showNetplayOnMount ?? false);
532
+ // Track if we're returning from a netplay disconnect (to default to Join mode)
533
+ const [netplayReturnFromDisconnect, setNetplayReturnFromDisconnect] = useState(showNetplayOnMount ?? false);
534
+
535
+ // Core manager panel state
536
+ const [showCoreManager, setShowCoreManager] = useState(false);
537
+
538
+ // Filter ROMs based on search query (memoized to avoid filtering on every render)
539
+ const filteredRoms = useMemo(() => filterRoms(roms, debouncedSearchQuery), [roms, debouncedSearchQuery]);
540
+
541
+ // Calculate dimensions - use Ink's stdout for native mode compatibility
542
+ const termWidth = stdout.columns || DEFAULT_TERM_WIDTH;
543
+ const termHeight = stdout.rows || DEFAULT_TERM_HEIGHT;
544
+
545
+ // Layout: left panel (ROM list) takes 60%, right panel (metadata) takes 40%
546
+ const LAYOUT_BORDER_PADDING = 4;
547
+ const listWidth = Math.floor((termWidth - LAYOUT_BORDER_PADDING) * PERCENT_60);
548
+ const metaWidth = Math.floor((termWidth - LAYOUT_BORDER_PADDING) * PERCENT_40);
549
+ const listHeight = termHeight - ROM_OPTIONS_PANEL_ROWS; // Account for header and margins
550
+
551
+ // Visible items in the list (each item takes 2 lines: content + separator)
552
+ const MIN_ITEMS = 1;
553
+ const ITEMS_PER_ROW = 2;
554
+ const visibleItems = Math.max(MIN_ITEMS, Math.floor((listHeight - 1) / ITEMS_PER_ROW));
555
+
556
+ // Calculate scrollbar dimensions (needed for mouse handling)
557
+ const scrollbarTrackHeight = listHeight - 2; // Subtract 2 for top/bottom border
558
+
559
+ // Refs for values that need to be current in callbacks (avoids stale closures)
560
+ const scrollOffsetRef = useRef(scrollOffset);
561
+ const visibleItemsRef = useRef(visibleItems);
562
+ const filteredRomsRef = useRef(filteredRoms);
563
+ scrollOffsetRef.current = scrollOffset;
564
+ visibleItemsRef.current = visibleItems;
565
+ filteredRomsRef.current = filteredRoms;
566
+
567
+ // Track if this is the first render (to skip resetting on mount)
568
+ const isFirstRenderRef = useRef(true);
569
+
570
+ // Reset selection when filter changes (but not on initial mount)
571
+ useEffect(() => {
572
+ if (isFirstRenderRef.current) {
573
+ isFirstRenderRef.current = false;
574
+ return;
575
+ }
576
+ setSelectedIndex(0);
577
+ setScrollOffset(0);
578
+ }, [debouncedSearchQuery]);
579
+
580
+ // Mouse support for scrollbar and ROM list
581
+ const { stdin, setRawMode } = useStdin();
582
+
583
+ // Calculate positions for mouse hit detection
584
+ // Header takes ~3 rows, list box border takes 1 row, so content starts at row 5
585
+ const listStartRow = 4; // Row where list content begins (after header + border)
586
+ const listStartCol = 1; // Column where list starts
587
+ const listEndCol = listWidth - 2; // Column where list content ends (before scrollbar)
588
+ const scrollbarCol = listWidth - 1; // 1-indexed column position for scrollbar
589
+
590
+ // Enable mouse tracking and handle mouse events
591
+ // Mouse handlers are defined inside the effect to avoid stale closure issues.
592
+ // They use refs to access current state values.
593
+ useEffect(() => {
594
+ // Handle mouse click on scrollbar
595
+ const handleScrollbarClick = (clickRow: number) => {
596
+ const trackPosition = clickRow - listStartRow;
597
+ if (trackPosition < 0 || trackPosition >= scrollbarTrackHeight) {return;}
598
+
599
+ const scrollRatio = trackPosition / (scrollbarTrackHeight - 1);
600
+ const maxScroll = Math.max(0, filteredRomsRef.current.length - visibleItemsRef.current);
601
+ const newOffset = Math.round(scrollRatio * maxScroll);
602
+
603
+ setScrollOffset(newOffset);
604
+ setSelectedIndex(Math.min(newOffset, filteredRomsRef.current.length - 1));
605
+ };
606
+
607
+ // Handle mouse click on ROM list item
608
+ const handleListClick = (clickRow: number) => {
609
+ const rowInList = clickRow - listStartRow;
610
+ if (rowInList < 0 || rowInList >= scrollbarTrackHeight) {return;}
611
+
612
+ const itemIndex = Math.floor(rowInList / 2);
613
+ const absoluteIndex = scrollOffsetRef.current + itemIndex;
614
+
615
+ if (absoluteIndex >= 0 && absoluteIndex < filteredRomsRef.current.length) {
616
+ setSelectedIndex(absoluteIndex);
617
+ }
618
+ };
619
+
620
+ // Enable mouse tracking
621
+ process.stdout.write(ENABLE_MOUSE);
622
+ setRawMode(true);
623
+
624
+ let buffer = '';
625
+
626
+ // Pre-compiled regex for SGR mouse events (reused across calls)
627
+ const sgrRegex = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
628
+
629
+ const handleData = (data: Buffer) => {
630
+ buffer += data.toString();
631
+
632
+ // Guard against unbounded buffer growth
633
+ if (buffer.length > MOUSE_BUFFER_MAX_SIZE) {
634
+ const lastEsc = buffer.lastIndexOf('\x1b');
635
+ buffer = lastEsc >= 0 ? buffer.slice(lastEsc) : '';
636
+ }
637
+
638
+ // Parse SGR mouse events: \x1b[<button;x;yM (press) or \x1b[<button;x;ym (release)
639
+ sgrRegex.lastIndex = 0; // Reset regex state
640
+ let match;
641
+ let lastMatchEnd = 0;
642
+
643
+ while ((match = sgrRegex.exec(buffer)) !== null) {
644
+ lastMatchEnd = sgrRegex.lastIndex;
645
+ const button = parseInt(match[1], 10);
646
+ const x = parseInt(match[2], 10);
647
+ const y = parseInt(match[3], 10);
648
+ const isPress = match[4] === 'M';
649
+
650
+ // Only handle left click press (button 0)
651
+ if (isPress && button === 0) {
652
+ // Check if click is on scrollbar column
653
+ if (x === scrollbarCol || x === scrollbarCol + 1) {
654
+ handleScrollbarClick(y);
655
+ } else if (x >= listStartCol && x <= listEndCol) {
656
+ // Click is in the ROM list area
657
+ handleListClick(y);
658
+ }
659
+ }
660
+ }
661
+
662
+ // Clear processed data from buffer, keep only unprocessed tail
663
+ if (lastMatchEnd > 0) {
664
+ buffer = buffer.slice(lastMatchEnd);
665
+ }
666
+ };
667
+
668
+ stdin.on('data', handleData);
669
+
670
+ return () => {
671
+ stdin.off('data', handleData);
672
+ process.stdout.write(DISABLE_MOUSE);
673
+ };
674
+ }, [stdin, setRawMode, scrollbarCol, listStartCol, listEndCol, listStartRow, scrollbarTrackHeight]);
675
+
676
+ // Get selected ROM for display (may be undefined if filtered list is empty)
677
+ // Using .at() which properly returns T | undefined instead of bracket notation
678
+ const selectedRom = filteredRoms.at(selectedIndex);
679
+
680
+ // Cache for loaded save state details (keyed by ROM path)
681
+ const saveStateDetailsCacheRef = useRef<Map<string, SaveStateDetails>>(new Map());
682
+ // Cache for loaded thumbnails (keyed by ROM path)
683
+ const thumbnailCacheRef = useRef<Map<string, ThumbnailResult | null>>(new Map());
684
+ // Track which path's details are ready to render (after load + delay)
685
+ const [detailsReadyPath, setDetailsReadyPath] = useState<string | null>(null);
686
+
687
+ // Use detected Kitty graphics support for thumbnails (independent of video_driver setting)
688
+ const isKittySupported = useKittyGraphicsSupported();
689
+
690
+ // Load save state details and thumbnail lazily when selected ROM changes.
691
+ // IMPORTANT: File I/O is debounced to prevent blocking the event loop during
692
+ // rapid scrolling. Without debouncing, synchronous file reads (existsSync,
693
+ // readFileSync, gunzipSync) block input processing and cause the UI to freeze.
694
+ useEffect(() => {
695
+ // Handle empty filtered list (no ROM selected)
696
+ if (!selectedRom) {
697
+ setDetailsReadyPath(null);
698
+ return;
699
+ }
700
+ const romPath = selectedRom.path;
701
+ const hasSaveState = selectedRom.hasSaveState;
702
+ const romForThumbnail = selectedRom;
703
+
704
+ // Clear ready state while we wait for debounce
705
+ setDetailsReadyPath(null);
706
+
707
+ // Debounce the file I/O operations to avoid blocking during rapid scrolling
708
+ const timer = setTimeout(() => {
709
+ const saveStateCache = saveStateDetailsCacheRef.current;
710
+ const thumbCache = thumbnailCacheRef.current;
711
+
712
+ // Load save state details if not cached and ROM has a save state
713
+ if (hasSaveState && !saveStateCache.has(romPath)) {
714
+ const details = getSaveStateService().loadDetails(romPath);
715
+ saveStateCache.set(romPath, details);
716
+ }
717
+
718
+ // Load thumbnail if not cached (works with both Kitty and half-block renderers)
719
+ if (!thumbCache.has(romPath)) {
720
+ const thumbnail = loadAnyThumbnail(romForThumbnail);
721
+ thumbCache.set(romPath, thumbnail ?? null);
722
+ }
723
+
724
+ setDetailsReadyPath(romPath);
725
+ }, THUMBNAIL_LOAD_DELAY_MS);
726
+
727
+ return () => clearTimeout(timer);
728
+ }, [selectedRom?.path, selectedRom?.hasSaveState]);
729
+
730
+ // Get the details for the current selection (from ref cache)
731
+ const selectedRomDetails = selectedRom?.hasSaveState && detailsReadyPath === selectedRom.path
732
+ ? saveStateDetailsCacheRef.current.get(selectedRom.path)
733
+ : undefined;
734
+
735
+ // Get the thumbnail for the current selection (from ref cache)
736
+ const selectedRomThumbnail = selectedRom && detailsReadyPath === selectedRom.path
737
+ ? (thumbnailCacheRef.current.get(selectedRom.path) ?? undefined)
738
+ : undefined;
739
+
740
+ // Migrate save state timestamp to playlist last_played if not already set
741
+ // This is a one-time migration for ROMs that were played before last_played tracking was added
742
+ const migratedDatesRef = useRef<Map<string, Date>>(new Map());
743
+ useEffect(() => {
744
+ if (!selectedRom || selectedRom.lastPlayed) {
745
+ return;
746
+ }
747
+
748
+ // Only migrate from save state savedAt
749
+ const dateToMigrate = selectedRomDetails?.savedAt;
750
+ if (!dateToMigrate) {
751
+ return;
752
+ }
753
+
754
+ // Skip if already migrated this session
755
+ if (migratedDatesRef.current.has(selectedRom.path)) {
756
+ return;
757
+ }
758
+
759
+ // Store migrated date and update playlist
760
+ migratedDatesRef.current.set(selectedRom.path, dateToMigrate);
761
+ updateLastPlayed(selectedRom.path, playlistDirectory, dateToMigrate);
762
+ }, [selectedRom, selectedRomDetails?.savedAt, playlistDirectory]);
763
+
764
+ // Create ROM with lastPlayed populated (from playlist or migration)
765
+ const selectedRomWithLastPlayed = useMemo(() => {
766
+ if (!selectedRom) {
767
+ return undefined;
768
+ }
769
+ if (selectedRom.lastPlayed) {
770
+ return selectedRom;
771
+ }
772
+ const migratedDate = migratedDatesRef.current.get(selectedRom.path);
773
+ if (migratedDate) {
774
+ return { ...selectedRom, lastPlayed: migratedDate };
775
+ }
776
+ return selectedRom;
777
+ }, [selectedRom, selectedRomDetails?.savedAt]); // Re-compute when savedAt loads (triggers migration)
778
+
779
+ // Handle keyboard input
780
+ useInput((input, key) => {
781
+ // Skip main input handling when overlays are shown
782
+ if (showAddRomsPrompt || showSettings || showNetplay || showCoreManager) {
783
+ return;
784
+ }
785
+
786
+ // Tab: toggle focus between ROM list and action buttons
787
+ if (key.tab) {
788
+ setActionButtonsFocused(prev => !prev);
789
+ return;
790
+ }
791
+
792
+ // ESC: if action buttons focused, return to list; otherwise clear filter or show settings
793
+ if (key.escape) {
794
+ if (actionButtonsFocused) {
795
+ setActionButtonsFocused(false);
796
+ return;
797
+ }
798
+ if (searchQuery) {
799
+ setSearchQuery('');
800
+ } else {
801
+ setShowSettings(true);
802
+ }
803
+ return;
804
+ }
805
+
806
+ // Backspace: remove last character from search (only when list is focused)
807
+ if ((key.backspace || key.delete) && !actionButtonsFocused) {
808
+ setSearchQuery(prev => prev.slice(0, -1));
809
+ return;
810
+ }
811
+
812
+ // When action buttons are focused, handle horizontal navigation
813
+ if (actionButtonsFocused) {
814
+ if (key.leftArrow) {
815
+ setActionButtonIndex(prev => Math.max(0, prev - 1));
816
+ return;
817
+ }
818
+ if (key.rightArrow) {
819
+ setActionButtonIndex(prev => Math.min(actionButtons.length - 1, prev + 1));
820
+ return;
821
+ }
822
+ if (key.return) {
823
+ // Handle action button activation
824
+ const button = actionButtons[actionButtonIndex];
825
+ if (button.id === 'add-roms') {
826
+ setShowAddRomsPrompt(true);
827
+ setActionButtonsFocused(false);
828
+ } else if (button.id === 'manage-cores') {
829
+ setShowCoreManager(true);
830
+ setActionButtonsFocused(false);
831
+ } else if (button.id === 'netplay') {
832
+ // Can only start netplay if a ROM is selected
833
+ if (filteredRoms.length > 0) {
834
+ setShowNetplay(true);
835
+ setActionButtonsFocused(false);
836
+ }
837
+ } else if (button.id === 'settings') {
838
+ setShowSettings(true);
839
+ setActionButtonsFocused(false);
840
+ }
841
+ return;
842
+ }
843
+ // Ignore other keys when action buttons focused
844
+ return;
845
+ }
846
+
847
+ // Navigation with arrow keys and page up/down only (when ROM list is focused)
848
+ // Note: We only update selectedIndex here. The useEffect keeps scrollOffset in sync.
849
+ // We use refs for filteredRoms and visibleItems to avoid stale closure issues
850
+ // during rapid key presses.
851
+ if (key.upArrow) {
852
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
853
+ return;
854
+ }
855
+
856
+ if (key.downArrow) {
857
+ setSelectedIndex((prev) => Math.min(filteredRomsRef.current.length - 1, prev + 1));
858
+ return;
859
+ }
860
+
861
+ if (key.pageUp) {
862
+ setSelectedIndex((prev) => Math.max(0, prev - visibleItemsRef.current));
863
+ return;
864
+ }
865
+
866
+ if (key.pageDown) {
867
+ setSelectedIndex((prev) => Math.min(filteredRomsRef.current.length - 1, prev + visibleItemsRef.current));
868
+ return;
869
+ }
870
+
871
+ // Home/End for jumping to top/bottom
872
+ if (key.home) {
873
+ setSelectedIndex(0);
874
+ return;
875
+ }
876
+
877
+ if (key.end) {
878
+ setSelectedIndex(filteredRomsRef.current.length - 1);
879
+ return;
880
+ }
881
+
882
+ if (key.return) {
883
+ const rom = filteredRoms.at(selectedIndex);
884
+ if (!rom) {return;} // No ROM selected (empty filtered list)
885
+ onSelect(rom, searchQuery);
886
+ exit();
887
+ return;
888
+ }
889
+
890
+ // Any printable character adds to search
891
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
892
+ setSearchQuery(prev => prev + input);
893
+ }
894
+ });
895
+
896
+ // Handle gamepad input
897
+ // Note: Like keyboard input, we only update selectedIndex here and let the
898
+ // useEffect sync scrollOffset. We use refs to avoid stale closure issues.
899
+ useGamepadContext({
900
+ onUp: () => {
901
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
902
+ },
903
+ onDown: () => {
904
+ setSelectedIndex((prev) => Math.min(filteredRomsRef.current.length - 1, prev + 1));
905
+ },
906
+ onConfirm: () => {
907
+ const rom = filteredRoms.at(selectedIndex);
908
+ if (!rom) {return;} // No ROM selected (empty filtered list)
909
+ onSelect(rom, searchQuery);
910
+ exit();
911
+ },
912
+ onCancel: () => {
913
+ // B button clears filter but doesn't exit app (use keyboard Esc to exit)
914
+ if (searchQuery) {
915
+ setSearchQuery('');
916
+ }
917
+ },
918
+ onGuide: () => {
919
+ // Guide/Xbox button opens settings
920
+ setShowSettings(true);
921
+ },
922
+ }, !showSettings && !showAddRomsPrompt && !showNetplay && !showCoreManager); // Disable when overlays are shown
923
+
924
+ // Keep scroll in sync with selection
925
+ // Uses refs to read current values without triggering effect re-runs when scrollOffset changes
926
+ useEffect(() => {
927
+ const currentScrollOffset = scrollOffsetRef.current;
928
+ const currentVisibleItems = visibleItemsRef.current;
929
+ if (selectedIndex < currentScrollOffset) {
930
+ setScrollOffset(selectedIndex);
931
+ } else if (selectedIndex >= currentScrollOffset + currentVisibleItems) {
932
+ setScrollOffset(selectedIndex - currentVisibleItems + 1);
933
+ }
934
+ }, [selectedIndex]);
935
+
936
+ // Clamp selection to valid range when filtered list changes
937
+ useEffect(() => {
938
+ if (selectedIndex >= filteredRoms.length) {
939
+ setSelectedIndex(Math.max(0, filteredRoms.length - 1));
940
+ }
941
+ }, [filteredRoms.length, selectedIndex]);
942
+
943
+ // Get visible ROMs for current scroll position
944
+ const visibleRoms = filteredRoms.slice(scrollOffset, scrollOffset + visibleItems);
945
+
946
+ // Calculate scrollbar position and size
947
+ const scrollbarHeight = Math.max(1, Math.floor(scrollbarTrackHeight * visibleItems / filteredRoms.length));
948
+ const scrollbarPosition = Math.floor((scrollOffset / Math.max(1, filteredRoms.length - visibleItems)) * (scrollbarTrackHeight - scrollbarHeight));
949
+
950
+ // Empty space elements to fill unused list rows
951
+ const emptySpaceCount = Math.max(0, visibleItems - visibleRoms.length);
952
+ const emptySpaceElements = Array.from({ length: emptySpaceCount }, (_, i) => (
953
+ <Box key={`empty-${i}`}>
954
+ <Text> </Text>
955
+ </Box>
956
+ ));
957
+
958
+ // Scrollbar track elements
959
+ const scrollbarElements = Array.from({ length: scrollbarTrackHeight }, (_, i) => {
960
+ const isThumb = i >= scrollbarPosition && i < scrollbarPosition + scrollbarHeight;
961
+ return (
962
+ <Text key={i} color={isThumb ? 'blue' : 'gray'}>
963
+ {isThumb ? '\u2588' : '\u2591'}
964
+ </Text>
965
+ );
966
+ });
967
+
968
+ // Handle Add ROMs prompt completion
969
+ const handleAddRomsComplete = useCallback(() => {
970
+ // Trigger a refresh to reload the ROM browser with the updated ROM list
971
+ onRefresh(searchQuery);
972
+ exit();
973
+ }, [searchQuery, onRefresh, exit]);
974
+
975
+ const handleAddRomsCancel = useCallback(() => {
976
+ setShowAddRomsPrompt(false);
977
+ }, []);
978
+
979
+ // Show Add ROMs prompt overlay when active
980
+ if (showAddRomsPrompt) {
981
+ return (
982
+ <AddRomsPrompt
983
+ directory={process.cwd()}
984
+ playlistDirectory={playlistDirectory}
985
+ scanDepth={scanDepth}
986
+ onPlaylistGenerated={handleAddRomsComplete}
987
+ onExit={handleAddRomsCancel}
988
+ />
989
+ );
990
+ }
991
+
992
+ // Show core manager panel when active
993
+ if (showCoreManager) {
994
+ return (
995
+ <CoreManager
996
+ onClose={() => setShowCoreManager(false)}
997
+ />
998
+ );
999
+ }
1000
+
1001
+ // Show netplay panel when active
1002
+ if (showNetplay) {
1003
+ // Use the ROM at selected index, or fall back to first ROM in list
1004
+ const selectedRomForNetplay = filteredRoms.at(selectedIndex) ?? filteredRoms.at(0);
1005
+ if (selectedRomForNetplay) {
1006
+ return (
1007
+ <NetplayPanel
1008
+ rom={selectedRomForNetplay}
1009
+ onStart={(options) => {
1010
+ setNetplayReturnFromDisconnect(false);
1011
+ onSelect(selectedRomForNetplay, searchQuery, false, options);
1012
+ exit();
1013
+ }}
1014
+ onCancel={() => {
1015
+ setNetplayReturnFromDisconnect(false);
1016
+ setShowNetplay(false);
1017
+ }}
1018
+ initialMode={netplayReturnFromDisconnect ? 'join' : 'host'}
1019
+ />
1020
+ );
1021
+ }
1022
+ // No ROMs available - fall through to show main browser
1023
+ }
1024
+
1025
+ // Show settings panel when active
1026
+ if (showSettings) {
1027
+ return (
1028
+ <SettingsPanel
1029
+ onClose={() => {
1030
+ // Reload config in case settings changed
1031
+ reloadConfig();
1032
+ setShowSettings(false);
1033
+ // Clear resumable ROM so Resume Game doesn't appear next time settings is opened
1034
+ setResumableRom(undefined);
1035
+ }}
1036
+ lastPlayedRom={resumableRom}
1037
+ onResumeGame={resumableRom ? () => {
1038
+ onSelect(resumableRom, searchQuery, true); // true = resumeGame
1039
+ exit();
1040
+ } : undefined}
1041
+ />
1042
+ );
1043
+ }
1044
+
1045
+ // Empty state - no ROMs found
1046
+ if (roms.length === 0) {
1047
+ return (
1048
+ <Box flexDirection="column" height={termHeight}>
1049
+ <Header romCount={0} totalCount={0} searchQuery="" />
1050
+
1051
+ {/* Empty state message - fills available space */}
1052
+ <Box flexGrow={1} alignItems="center" justifyContent="center">
1053
+ <EmptyState />
1054
+ </Box>
1055
+
1056
+ {/* Footer with action buttons */}
1057
+ <Box marginTop={1}>
1058
+ {actionButtons.map((btn, i) => (
1059
+ <ActionButton
1060
+ key={btn.id}
1061
+ button={btn}
1062
+ isSelected={i === actionButtonIndex}
1063
+ isFocused={actionButtonsFocused}
1064
+ highlightBg={localConfig.menu_highlight_bg}
1065
+ highlightFg={localConfig.menu_highlight_fg}
1066
+ />
1067
+ ))}
1068
+ <Text color="gray" dimColor>
1069
+ {actionButtonsFocused ? ' (Tab: list, \u2190\u2192: select, \u23CE: activate)' : ' (Tab: actions)'}
1070
+ </Text>
1071
+ </Box>
1072
+ </Box>
1073
+ );
1074
+ }
1075
+
1076
+ return (
1077
+ <Box flexDirection="column">
1078
+ <Header romCount={filteredRoms.length} totalCount={roms.length} searchQuery={searchQuery} />
1079
+
1080
+ <Box>
1081
+ {/* ROM List */}
1082
+ <Box
1083
+ flexDirection="column"
1084
+ width={listWidth}
1085
+ height={listHeight}
1086
+ borderStyle="round"
1087
+ borderColor="gray"
1088
+ >
1089
+ {visibleRoms.map((rom, i) => (
1090
+ <Box key={rom.path} flexDirection="column">
1091
+ <RomListItem
1092
+ rom={rom}
1093
+ isSelected={scrollOffset + i === selectedIndex}
1094
+ width={listWidth - LAYOUT_BORDER_PADDING}
1095
+ highlightBg={localConfig.menu_highlight_bg}
1096
+ highlightFg={localConfig.menu_highlight_fg}
1097
+ />
1098
+ {i < visibleRoms.length - 1 && (
1099
+ <Text color="gray" dimColor>{'─'.repeat(listWidth - LAYOUT_BORDER_PADDING)}</Text>
1100
+ )}
1101
+ </Box>
1102
+ ))}
1103
+
1104
+ {/* Fill empty space (memoized) */}
1105
+ {emptySpaceElements}
1106
+
1107
+ {/* Scrollbar indicator (memoized) */}
1108
+ {filteredRoms.length > visibleItems && (
1109
+ <Box position="absolute" flexDirection="column" marginLeft={listWidth - ROM_SEPARATOR_PADDING}>
1110
+ {scrollbarElements}
1111
+ </Box>
1112
+ )}
1113
+ </Box>
1114
+
1115
+ {/* Spacer */}
1116
+ <Box width={ITEMS_PER_ROW} />
1117
+
1118
+ {/* Metadata Panel */}
1119
+ <MetadataPanel
1120
+ rom={selectedRomWithLastPlayed}
1121
+ width={metaWidth}
1122
+ height={listHeight}
1123
+ saveStateDetails={selectedRomDetails}
1124
+ thumbnail={selectedRomThumbnail?.data}
1125
+ isKittySupported={isKittySupported}
1126
+ panelStartCol={listWidth + ITEMS_PER_ROW + 1}
1127
+ />
1128
+ </Box>
1129
+
1130
+ {/* Footer with action buttons and position indicator */}
1131
+ <Box marginTop={1} justifyContent="space-between">
1132
+ {/* Action buttons */}
1133
+ <Box>
1134
+ {actionButtons.map((btn, i) => (
1135
+ <ActionButton
1136
+ key={btn.id}
1137
+ button={btn}
1138
+ isSelected={i === actionButtonIndex}
1139
+ isFocused={actionButtonsFocused}
1140
+ highlightBg={localConfig.menu_highlight_bg}
1141
+ highlightFg={localConfig.menu_highlight_fg}
1142
+ />
1143
+ ))}
1144
+ <Text color="gray" dimColor>
1145
+ {actionButtonsFocused ? ' (Tab: list, \u2190\u2192: select, \u23CE: activate)' : ' (Tab: actions)'}
1146
+ </Text>
1147
+ </Box>
1148
+
1149
+ {/* Position indicator */}
1150
+ <Box>
1151
+ <Text color="gray">
1152
+ {filteredRoms.length > 0 ? `${selectedIndex + 1}/${filteredRoms.length}` : 'No matches'}
1153
+ {!searchQuery && !actionButtonsFocused && (
1154
+ <Text color="gray" dimColor> (type to search)</Text>
1155
+ )}
1156
+ {searchQuery && !actionButtonsFocused && (
1157
+ <Text color="gray" dimColor> (ESC to clear)</Text>
1158
+ )}
1159
+ </Text>
1160
+ </Box>
1161
+ </Box>
1162
+ </Box>
1163
+ );
1164
+ };