@ciphore/radiocli 0.1.9 → 0.2.1

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 (58) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +53 -3
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +179 -98
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +28 -8
  14. package/dist/providers/radio-browser.js +141 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +264 -149
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +489 -54
  20. package/dist/ui/AppContent.js +11 -12
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +10 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +15 -2
  35. package/dist/ui/screen-items.js +60 -21
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +109 -10
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +44 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +37 -6
  52. package/dist/ui/use-command-executor.js +34 -1
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/dist/update-check.js +122 -0
  56. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  57. package/package.json +2 -2
  58. package/dist/ui/screens/screen-render.test.js +0 -214
package/dist/ui/App.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
4
4
  import { ProviderManager } from '../providers/provider-manager.js';
@@ -13,26 +13,37 @@ import { AppContent } from './AppContent.js';
13
13
  import { TopTabs } from './components/TopTabs.js';
14
14
  import { computeTerminalLayout } from './layout.js';
15
15
  import { truncate } from './format.js';
16
- import { playbackFooterText, shouldShowPlaybackFooter } from './playback-footer.js';
17
- import { pageFooterText } from './page-footer.js';
18
- import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell } from './terminal-mouse.js';
16
+ import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
17
+ import { fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, pageFooterText } from './page-footer.js';
18
+ import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
19
19
  import { useAppInput } from './use-app-input.js';
20
20
  import { useCommandExecutor } from './use-command-executor.js';
21
21
  import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
22
22
  import { isAirPlayBackendAvailable } from './airplay-settings.js';
23
23
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
24
24
  import { copyToClipboard, openExternal } from './system-actions.js';
25
+ import { appVersion } from '../version.js';
26
+ import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
27
+ import { helpItemCount } from './help-content.js';
28
+ import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
25
29
  import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextReceiverPulse, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldResetReceiverPulse, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
26
30
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
27
31
  const LIVE_RECEIVER_PULSE_MS = 80;
32
+ const SKYLINE_RECEIVER_PULSE_MS = 120;
28
33
  const AMBIENT_RECEIVER_PULSE_MS = 140;
29
34
  const LOADING_SPINNER_MS = 120;
30
35
  const VISUALIZER_MESSAGE_MS = 4500;
36
+ const LISTENING_HEARTBEAT_MS = 30_000;
37
+ const COUNTRY_STATIONS_PAGE_SIZE = 120;
38
+ const COUNTRY_STATIONS_LOAD_AHEAD = 12;
39
+ const SEARCH_RESULTS_PAGE_SIZE = 90;
40
+ const SEARCH_RESULTS_LOAD_AHEAD = 12;
31
41
  const settingToggleLabel = {
32
42
  resumeOnLaunch: 'Resume on launch',
33
43
  transparentBackground: 'Transparent background',
34
44
  asciiMode: 'ASCII-safe display',
35
- reduceMotion: 'Reduce motion'
45
+ reduceMotion: 'Reduce motion',
46
+ mouseSupport: 'Mouse and trackpad scrolling'
36
47
  };
37
48
  export function App({ store: providedStore, providers: providedProviders }) {
38
49
  const { exit } = useApp();
@@ -41,6 +52,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
41
52
  const { columns, rows } = useWindowSize();
42
53
  const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
43
54
  const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
55
+ const installedVersion = useMemo(() => appVersion(), []);
44
56
  const [library, setLibrary] = useState(() => store.snapshot());
45
57
  const settingsRef = useRef(library.settings);
46
58
  settingsRef.current = library.settings;
@@ -61,6 +73,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
61
73
  const [searchQuery, setSearchQuery] = useState('');
62
74
  const [editingSearch, setEditingSearch] = useState(true);
63
75
  const [playingStation, setPlayingStation] = useState(null);
76
+ const [tuningStation, setTuningStation] = useState(null);
64
77
  const [nowPlaying, setNowPlaying] = useState(null);
65
78
  const [location, setLocation] = useState(null);
66
79
  const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
@@ -74,9 +87,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
74
87
  const [sleepUntil, setSleepUntil] = useState(null);
75
88
  const [showDiagnostics, setShowDiagnostics] = useState(false);
76
89
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
90
+ const announcedUpdateRef = useRef(false);
91
+ const installingUpdateRef = useRef(false);
77
92
  const displayStationsRef = useRef([]);
78
93
  const playbackQueueRef = useRef(null);
79
94
  const lastRawTransportAtRef = useRef(0);
95
+ const loadingStationsRef = useRef(false);
96
+ const countryPageRequestRef = useRef(null);
97
+ const searchPageRequestRef = useRef(null);
80
98
  const playStationRef = useRef(() => undefined);
81
99
  const screenRef = useRef(screen);
82
100
  const selectedRef = useRef(selected);
@@ -90,7 +108,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
90
108
  const exploreMoveTimerRef = useRef(null);
91
109
  const transientMessageTimerRef = useRef(null);
92
110
  const transientFooterMessageTimerRef = useRef(null);
111
+ const exitConfirmationUntilRef = useRef(0);
93
112
  const receiverPulseSnapshotRef = useRef(null);
113
+ const helpReturnScreenRef = useRef('home');
114
+ const countriesLoadAttemptedRef = useRef(false);
115
+ const tuneRequestRef = useRef(0);
116
+ const searchRequestRef = useRef(0);
117
+ const countryRequestRef = useRef(0);
118
+ const nearbyRequestRef = useRef(0);
119
+ const skipBrokenTimerRef = useRef(null);
120
+ const volumeRequestRef = useRef(0);
94
121
  const theme = library.settings.theme;
95
122
  const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
96
123
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
@@ -114,6 +141,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
114
141
  }), [library, libraryStations, stationContexts]);
115
142
  screenRef.current = screen;
116
143
  selectedRef.current = selected;
144
+ loadingStationsRef.current = loadingStations;
117
145
  stationContextsRef.current = activeStationContexts;
118
146
  exploreCursorRef.current = exploreCursor;
119
147
  const renderedStationContextKey = stationContextKeyForScreen(screen);
@@ -140,7 +168,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
140
168
  'airplay-settings': 0,
141
169
  'airplay-code': 1,
142
170
  settings: settingsItems.length,
143
- help: 0
171
+ help: helpItemCount
144
172
  });
145
173
  itemCountsRef.current = {
146
174
  home: homeItems.length,
@@ -156,37 +184,75 @@ export function App({ store: providedStore, providers: providedProviders }) {
156
184
  'airplay-settings': availableAirPlayDevices.length,
157
185
  'airplay-code': 1,
158
186
  settings: settingsItems.length,
159
- help: 0
187
+ help: helpItemCount
160
188
  };
161
189
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
162
190
  displayStationsRef.current = displayStations;
163
191
  const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
164
- const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
165
- const footerRows = showPlaybackFooter ? 4 : 3;
192
+ const footerStation = tuningStation ?? playingStation;
193
+ const footerPlayback = playbackStateForPendingStation(playback, tuningStation);
194
+ // Now Playing already owns the station identity, so its transient notice can
195
+ // reuse the playback-status row that other full-size screens need.
196
+ const footerRows = fullFooterRowCount(screen);
166
197
  const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
167
198
  const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
168
199
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
169
200
  const layout = computeTerminalLayout(columns, rows, footerRows);
170
- const frameWidth = Math.max(40, layout.columns - 2);
201
+ const frameWidth = layout.frameWidth;
202
+ const mouseReportingActive = !commandMode &&
203
+ !capturingTransportAction &&
204
+ !editingCountryFilter &&
205
+ shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
171
206
  useEffect(() => player.onChange(setPlayback), [player]);
172
207
  const playingStationRef = useRef(null);
173
208
  playingStationRef.current = playingStation;
209
+ const activeListeningStationRef = useRef(null);
210
+ const lastRecordedTrackRef = useRef(null);
211
+ useEffect(() => {
212
+ const station = playingStationRef.current;
213
+ const isAudible = playback.state === 'playing' && playback.ready && station;
214
+ if (isAudible) {
215
+ const key = stationKey(station);
216
+ if (activeListeningStationRef.current !== key) {
217
+ activeListeningStationRef.current = key;
218
+ setLibrary(store.startListeningSession(station));
219
+ }
220
+ return;
221
+ }
222
+ if (activeListeningStationRef.current) {
223
+ activeListeningStationRef.current = null;
224
+ setLibrary(store.finishActiveListeningSession());
225
+ }
226
+ }, [playback.ready, playback.state, playingStation, store]);
227
+ useEffect(() => {
228
+ if (playback.state !== 'playing' || !playback.ready || !playingStation) {
229
+ return;
230
+ }
231
+ const timer = setInterval(() => {
232
+ setLibrary(store.checkpointActiveListeningSession());
233
+ }, LISTENING_HEARTBEAT_MS);
234
+ return () => clearInterval(timer);
235
+ }, [playback.ready, playback.state, playingStation, store]);
174
236
  useEffect(() => player.onMetadata(metadata => {
175
237
  setNowPlaying(metadata);
176
238
  const station = playingStationRef.current;
177
239
  if (station && metadata.title) {
240
+ const trackKey = `${stationKey(station)}:${metadata.title}`;
241
+ if (lastRecordedTrackRef.current === trackKey)
242
+ return;
243
+ lastRecordedTrackRef.current = trackKey;
178
244
  setLibrary(store.recordTrack(station, metadata.title));
179
245
  }
180
246
  }), [player, store]);
181
247
  useEffect(() => {
182
- if (screen !== 'explore' || layout.compact || !stdout.isTTY) {
248
+ if (!stdout.isTTY || !mouseReportingActive) {
183
249
  return;
184
250
  }
185
251
  stdout.write(enableMouseReporting);
186
252
  return () => {
187
253
  stdout.write(disableMouseReporting);
188
254
  };
189
- }, [layout.compact, screen, stdout]);
255
+ }, [mouseReportingActive, stdout]);
190
256
  useEffect(() => {
191
257
  selectedByScreenRef.current[screen] = selected;
192
258
  if (renderedStationContextKey) {
@@ -212,12 +278,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
212
278
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
213
279
  return;
214
280
  }
215
- const intervalMs = LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle) ? LIVE_RECEIVER_PULSE_MS : AMBIENT_RECEIVER_PULSE_MS;
281
+ const intervalMs = library.settings.receiverStyle === 'skyline'
282
+ ? SKYLINE_RECEIVER_PULSE_MS
283
+ : LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle)
284
+ ? LIVE_RECEIVER_PULSE_MS
285
+ : AMBIENT_RECEIVER_PULSE_MS;
216
286
  const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
217
287
  return () => clearInterval(timer);
218
288
  }, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
219
289
  useEffect(() => {
220
- if (playback.state !== 'loading' ||
290
+ if (footerPlayback.state !== 'loading' ||
221
291
  library.settings.reduceMotion ||
222
292
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
223
293
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
@@ -226,9 +296,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
226
296
  }
227
297
  const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
228
298
  return () => clearInterval(timer);
229
- }, [library.settings.reduceMotion, playback.state]);
299
+ }, [footerPlayback.state, library.settings.reduceMotion]);
230
300
  useEffect(() => {
231
- if ((screen === 'countries' || screen === 'map') && countries.length === 0 && !loadingCountries) {
301
+ if ((screen === 'countries' || screen === 'map') &&
302
+ countries.length === 0 &&
303
+ !loadingCountries &&
304
+ !countriesLoadAttemptedRef.current) {
305
+ countriesLoadAttemptedRef.current = true;
232
306
  setLoadingCountries(true);
233
307
  providers
234
308
  .countries()
@@ -247,6 +321,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
247
321
  if (transientFooterMessageTimerRef.current) {
248
322
  clearTimeout(transientFooterMessageTimerRef.current);
249
323
  }
324
+ if (skipBrokenTimerRef.current) {
325
+ clearTimeout(skipBrokenTimerRef.current);
326
+ }
250
327
  }, []);
251
328
  useEffect(() => {
252
329
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
@@ -257,12 +334,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
257
334
  }
258
335
  const delayMs = sleepUntil - Date.now();
259
336
  if (delayMs <= 0) {
337
+ tuneRequestRef.current += 1;
260
338
  setLibrary(store.finishActiveListeningSession());
261
339
  void player.stop();
262
340
  setSleepUntil(null);
263
341
  return;
264
342
  }
265
343
  const timer = setTimeout(() => {
344
+ tuneRequestRef.current += 1;
266
345
  setLibrary(store.finishActiveListeningSession());
267
346
  void player.stop();
268
347
  setSleepUntil(null);
@@ -283,6 +362,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
283
362
  useEffect(() => {
284
363
  refreshProviderHealth();
285
364
  }, [refreshProviderHealth]);
365
+ const refreshUpdateCheck = useCallback(async () => {
366
+ const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
367
+ setLibrary(store.updateCheckState(updateCheck));
368
+ return updateCheck;
369
+ }, [installedVersion, store]);
370
+ useEffect(() => {
371
+ if (!shouldCheckForUpdate(library.updateCheck)) {
372
+ return;
373
+ }
374
+ let cancelled = false;
375
+ void refreshUpdateCheck().then(updateCheck => {
376
+ if (cancelled) {
377
+ return;
378
+ }
379
+ if (updateCheck.updateAvailable && updateCheck.latestVersion && !announcedUpdateRef.current) {
380
+ announcedUpdateRef.current = true;
381
+ setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
382
+ }
383
+ });
384
+ return () => {
385
+ cancelled = true;
386
+ };
387
+ }, [library.updateCheck, refreshUpdateCheck]);
286
388
  const setStationContextFor = useCallback((key, context) => {
287
389
  setStationContexts(current => ({ ...current, [key]: context }));
288
390
  }, []);
@@ -324,14 +426,20 @@ export function App({ store: providedStore, providers: providedProviders }) {
324
426
  setMessage(`Learned ${mediaActionLabel(action)} key.`);
325
427
  }, [updateMediaKeys]);
326
428
  const go = useCallback((next, options = {}) => {
429
+ if (next === 'help' && screenRef.current !== 'help') {
430
+ helpReturnScreenRef.current = screenRef.current;
431
+ }
432
+ const destination = screenRef.current === 'help' && next === 'home'
433
+ ? helpReturnScreenRef.current
434
+ : next;
327
435
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
328
- const remembered = selectedByScreenRef.current[next] ?? 0;
436
+ const remembered = selectedByScreenRef.current[destination] ?? 0;
329
437
  const nextSelection = options.resetSelection ? 0 : remembered;
330
- if (next === 'now-playing' && screenRef.current !== 'now-playing') {
438
+ if (destination === 'now-playing' && screenRef.current !== 'now-playing') {
331
439
  setPulse(0);
332
440
  }
333
- setScreen(next);
334
- setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
441
+ setScreen(destination);
442
+ setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
335
443
  if (options.clearMessage !== false) {
336
444
  setMessage(null);
337
445
  }
@@ -346,9 +454,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
346
454
  }
347
455
  }, [openAirPlayCode, playback.backend, playback.message]);
348
456
  const shutdown = useCallback(() => {
457
+ tuneRequestRef.current += 1;
349
458
  store.finishActiveListeningSession();
350
459
  player.stop().finally(exit);
351
460
  }, [exit, player, store]);
461
+ useEffect(() => {
462
+ const finishForSignal = () => {
463
+ store.finishActiveListeningSession();
464
+ void player.stop().finally(() => process.exit(0));
465
+ };
466
+ process.once('SIGTERM', finishForSignal);
467
+ process.once('SIGHUP', finishForSignal);
468
+ return () => {
469
+ process.off('SIGTERM', finishForSignal);
470
+ process.off('SIGHUP', finishForSignal);
471
+ };
472
+ }, [player, store]);
352
473
  const showStationContext = useCallback((context, next = 'stations', options = {}) => {
353
474
  setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
354
475
  go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
@@ -431,41 +552,115 @@ export function App({ store: providedStore, providers: providedProviders }) {
431
552
  void loadExploreAt(next, { resetSelection: true, clearMessage: false });
432
553
  }, [frameWidth, layout, loadExploreAt]);
433
554
  const loadCountry = useCallback(async (country) => {
555
+ const requestId = countryRequestRef.current + 1;
556
+ countryRequestRef.current = requestId;
434
557
  setLoadingStations(true);
435
558
  setMessage(null);
559
+ countryPageRequestRef.current = null;
436
560
  try {
437
- const stations = await providers.byCountry(country.code, 120);
561
+ const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
562
+ if (requestId !== countryRequestRef.current)
563
+ return;
438
564
  showStationContext({
439
565
  title: country.name,
440
- subtitle: `${country.code} · ${country.stationCount.toLocaleString()} listed stations`,
441
- stations
566
+ subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
567
+ stations,
568
+ country,
569
+ hasMore: stations.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount
442
570
  }, 'stations');
443
571
  }
444
572
  catch (error) {
573
+ if (requestId !== countryRequestRef.current)
574
+ return;
445
575
  setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`);
446
576
  }
447
577
  finally {
448
- setLoadingStations(false);
578
+ if (requestId === countryRequestRef.current)
579
+ setLoadingStations(false);
449
580
  }
450
581
  }, [providers, showStationContext]);
582
+ const loadMoreCountryStations = useCallback(async () => {
583
+ const context = stationContextsRef.current.stations;
584
+ const country = context.country;
585
+ if (!country || !context.hasMore || loadingStationsRef.current) {
586
+ return;
587
+ }
588
+ const offset = context.stations.length;
589
+ const requestKey = `${country.code}:${offset}`;
590
+ if (countryPageRequestRef.current === requestKey) {
591
+ return;
592
+ }
593
+ countryPageRequestRef.current = requestKey;
594
+ setLoadingStations(true);
595
+ try {
596
+ const page = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset);
597
+ const latest = stationContextsRef.current.stations;
598
+ if (latest.country?.code !== country.code || latest.stations.length !== offset) {
599
+ return;
600
+ }
601
+ const stations = appendUniqueStations(latest.stations, page);
602
+ const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE &&
603
+ stations.length > latest.stations.length &&
604
+ stations.length < country.stationCount;
605
+ setStationContextFor('stations', {
606
+ ...latest,
607
+ subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
608
+ stations,
609
+ hasMore
610
+ });
611
+ }
612
+ catch (error) {
613
+ if (stationContextsRef.current.stations.country?.code === country.code) {
614
+ setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
615
+ }
616
+ }
617
+ finally {
618
+ if (countryPageRequestRef.current === requestKey) {
619
+ countryPageRequestRef.current = null;
620
+ setLoadingStations(false);
621
+ }
622
+ }
623
+ }, [providers, setStationContextFor]);
624
+ useEffect(() => {
625
+ if (screen === 'stations' &&
626
+ stationContexts.stations.country &&
627
+ stationContexts.stations.hasMore &&
628
+ stationContexts.stations.stations.length - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
629
+ void loadMoreCountryStations();
630
+ }
631
+ }, [
632
+ loadMoreCountryStations,
633
+ screen,
634
+ selected,
635
+ stationContexts.stations.country,
636
+ stationContexts.stations.hasMore,
637
+ stationContexts.stations.stations.length
638
+ ]);
451
639
  const runSearch = useCallback(async (query = searchQuery) => {
452
640
  if (!query.trim()) {
453
641
  setMessage('Enter a station, genre, language, or place.');
454
642
  return;
455
643
  }
644
+ const requestId = searchRequestRef.current + 1;
645
+ searchRequestRef.current = requestId;
456
646
  setLoadingStations(true);
457
647
  setMessage(null);
458
648
  try {
459
649
  const stations = await providers.search(query, settingsRef.current, {
460
- limit: 90,
650
+ limit: SEARCH_RESULTS_PAGE_SIZE,
651
+ offset: 0,
461
652
  codec: filters.codec ?? undefined,
462
653
  language: filters.language ?? undefined,
463
654
  minBitrate: filters.minBitrate ?? undefined
464
655
  });
656
+ if (requestId !== searchRequestRef.current)
657
+ return;
465
658
  setStationContextFor('search', {
466
659
  title: `Search: ${query}`,
467
- subtitle: 'Matches across enabled public station directories',
468
- stations
660
+ subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
661
+ stations,
662
+ query: query.trim(),
663
+ hasMore: stations.length >= SEARCH_RESULTS_PAGE_SIZE
469
664
  });
470
665
  selectedByScreenRef.current.search = 0;
471
666
  lastSubmittedSearchRef.current = query.trim();
@@ -475,12 +670,74 @@ export function App({ store: providedStore, providers: providedProviders }) {
475
670
  setEditingSearch(true);
476
671
  }
477
672
  catch (error) {
673
+ if (requestId !== searchRequestRef.current)
674
+ return;
478
675
  setMessage(error instanceof Error ? error.message : 'Search failed.');
479
676
  }
480
677
  finally {
481
- setLoadingStations(false);
678
+ if (requestId === searchRequestRef.current)
679
+ setLoadingStations(false);
482
680
  }
483
681
  }, [filters, providers, searchQuery, setStationContextFor, store]);
682
+ const loadMoreSearchResults = useCallback(async () => {
683
+ const context = stationContextsRef.current.search;
684
+ const query = context.query;
685
+ if (!query || !context.hasMore || loadingStationsRef.current) {
686
+ return;
687
+ }
688
+ const offset = context.stations.length;
689
+ const requestKey = `${query}:${offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
690
+ if (searchPageRequestRef.current === requestKey) {
691
+ return;
692
+ }
693
+ searchPageRequestRef.current = requestKey;
694
+ setLoadingStations(true);
695
+ try {
696
+ const page = await providers.search(query, settingsRef.current, {
697
+ limit: SEARCH_RESULTS_PAGE_SIZE,
698
+ offset,
699
+ codec: filters.codec ?? undefined,
700
+ language: filters.language ?? undefined,
701
+ minBitrate: filters.minBitrate ?? undefined
702
+ });
703
+ const latest = stationContextsRef.current.search;
704
+ if (latest.query !== query || latest.stations.length !== offset) {
705
+ return;
706
+ }
707
+ const stations = appendUniqueStations(latest.stations, page);
708
+ const hasMore = page.length >= SEARCH_RESULTS_PAGE_SIZE && stations.length > latest.stations.length;
709
+ setStationContextFor('search', {
710
+ ...latest,
711
+ subtitle: formatSearchSubtitle(stations.length, hasMore),
712
+ stations,
713
+ hasMore
714
+ });
715
+ }
716
+ catch (error) {
717
+ if (stationContextsRef.current.search.query === query) {
718
+ setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
719
+ }
720
+ }
721
+ finally {
722
+ if (searchPageRequestRef.current === requestKey) {
723
+ searchPageRequestRef.current = null;
724
+ setLoadingStations(false);
725
+ }
726
+ }
727
+ }, [filters, providers, setStationContextFor]);
728
+ useEffect(() => {
729
+ if (screen === 'search' &&
730
+ stationContexts.search.hasMore &&
731
+ stationContexts.search.stations.length - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
732
+ void loadMoreSearchResults();
733
+ }
734
+ }, [
735
+ loadMoreSearchResults,
736
+ screen,
737
+ selected,
738
+ stationContexts.search.hasMore,
739
+ stationContexts.search.stations.length
740
+ ]);
484
741
  const recallSearchHistory = useCallback((direction) => {
485
742
  const history = store.snapshot().searchHistory;
486
743
  if (history.length === 0) {
@@ -504,6 +761,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
504
761
  }
505
762
  }, [searchQuery, store]);
506
763
  const loadNearby = useCallback(async () => {
764
+ const requestId = nearbyRequestRef.current + 1;
765
+ nearbyRequestRef.current = requestId;
507
766
  setLoadingStations(true);
508
767
  setMessage(null);
509
768
  go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
@@ -521,6 +780,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
521
780
  return;
522
781
  }
523
782
  const detected = location ?? (await providers.detectLocation());
783
+ if (requestId !== nearbyRequestRef.current)
784
+ return;
524
785
  setLocation(detected);
525
786
  if (!detected) {
526
787
  if (stationContextsRef.current.nearby.stations.length > 0) {
@@ -535,6 +796,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
535
796
  return;
536
797
  }
537
798
  const stations = await providers.nearby(detected, 90);
799
+ if (requestId !== nearbyRequestRef.current)
800
+ return;
538
801
  setStationContextFor('nearby', {
539
802
  title: 'Nearby',
540
803
  subtitle: `${[detected.city, detected.region, detected.country].filter(Boolean).join(', ')} · ${detected.source}`,
@@ -542,10 +805,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
542
805
  });
543
806
  }
544
807
  catch (error) {
808
+ if (requestId !== nearbyRequestRef.current)
809
+ return;
545
810
  setMessage(error instanceof Error ? error.message : 'Could not load nearby stations.');
546
811
  }
547
812
  finally {
548
- setLoadingStations(false);
813
+ if (requestId === nearbyRequestRef.current)
814
+ setLoadingStations(false);
549
815
  }
550
816
  }, [go, location, providers, setStationContextFor]);
551
817
  const queueFromCurrentList = useCallback((station) => {
@@ -579,15 +845,31 @@ export function App({ store: providedStore, providers: providedProviders }) {
579
845
  }
580
846
  }, []);
581
847
  const playStation = useCallback(async (station, options = {}) => {
848
+ const requestId = tuneRequestRef.current + 1;
849
+ tuneRequestRef.current = requestId;
850
+ if (skipBrokenTimerRef.current) {
851
+ clearTimeout(skipBrokenTimerRef.current);
852
+ skipBrokenTimerRef.current = null;
853
+ }
582
854
  const queue = options.queue ?? queueFromCurrentList(station);
583
- setMessage(`Tuning ${station.name}...`);
855
+ setTuningStation(station);
584
856
  setNowPlaying(null);
585
857
  try {
858
+ // Cancel any in-flight backend startup before resolving the next tune.
859
+ // This prevents a slower, older request from becoming audible after a
860
+ // newer station was selected.
861
+ await player.stop();
862
+ if (requestId !== tuneRequestRef.current)
863
+ return;
586
864
  const resolved = await providers.resolve(station);
865
+ if (requestId !== tuneRequestRef.current)
866
+ return;
587
867
  await player.play(station, resolved.url);
868
+ if (requestId !== tuneRequestRef.current)
869
+ return;
588
870
  setPlayingStation(station);
871
+ setTuningStation(null);
589
872
  playbackQueueRef.current = queue;
590
- store.startListeningSession(station);
591
873
  const nextLibrary = store.addRecent(station);
592
874
  if (screenRef.current === 'library') {
593
875
  const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
@@ -604,16 +886,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
604
886
  setMessage(null);
605
887
  }
606
888
  catch (error) {
889
+ if (requestId !== tuneRequestRef.current)
890
+ return;
607
891
  const message = error instanceof Error ? error.message : 'Could not tune station.';
608
892
  const currentList = queue.stations;
609
893
  const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
610
894
  const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
611
895
  if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
612
896
  setMessage(`${message} Skipping to ${nextStation.name}.`);
897
+ setTuningStation(nextStation);
613
898
  rememberQueueSelection(queue, currentIndex + 1);
614
- setTimeout(() => playStationRef.current(nextStation, { ...options, queue }), 250);
899
+ skipBrokenTimerRef.current = setTimeout(() => {
900
+ skipBrokenTimerRef.current = null;
901
+ playStationRef.current(nextStation, { ...options, queue });
902
+ }, 250);
615
903
  return;
616
904
  }
905
+ setTuningStation(null);
617
906
  setMessage(message);
618
907
  }
619
908
  }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
@@ -634,7 +923,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
634
923
  }
635
924
  }, [playStation, store]);
636
925
  playStationRef.current = playStation;
637
- const showTransientFooterMessage = useCallback((nextMessage) => {
926
+ const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
638
927
  if (transientFooterMessageTimerRef.current) {
639
928
  clearTimeout(transientFooterMessageTimerRef.current);
640
929
  }
@@ -642,8 +931,17 @@ export function App({ store: providedStore, providers: providedProviders }) {
642
931
  transientFooterMessageTimerRef.current = setTimeout(() => {
643
932
  setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
644
933
  transientFooterMessageTimerRef.current = null;
645
- }, VISUALIZER_MESSAGE_MS);
934
+ }, durationMs);
646
935
  }, []);
936
+ const confirmCtrlCExit = useCallback(() => {
937
+ const decision = ctrlCExitDecision(exitConfirmationUntilRef.current, Date.now());
938
+ exitConfirmationUntilRef.current = decision.armedUntil;
939
+ if (decision.shouldExit) {
940
+ shutdown();
941
+ return;
942
+ }
943
+ showTransientFooterMessage('Ctrl+C again to exit', EXIT_CONFIRMATION_MS);
944
+ }, [showTransientFooterMessage, shutdown]);
647
945
  const toggleFavorite = useCallback((station) => {
648
946
  if (!station) {
649
947
  setMessage('Select or play a station before pressing f.');
@@ -658,7 +956,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
658
956
  else {
659
957
  setMessage(favoriteMessage);
660
958
  }
661
- if (!wasFavorite) {
959
+ if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
662
960
  // Best-effort upvote back to the directory; never blocks favoriting.
663
961
  void providers.vote(station);
664
962
  }
@@ -673,8 +971,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
673
971
  setMessage('This station has no homepage.');
674
972
  return;
675
973
  }
676
- openExternal(station.homepage);
677
- setMessage(`Opening homepage: ${station.name}`);
974
+ setMessage(openExternal(station.homepage)
975
+ ? `Opening homepage: ${station.name}`
976
+ : 'That station homepage is not a valid HTTP(S) URL.');
678
977
  }, []);
679
978
  const copyStationUrl = useCallback(async (station) => {
680
979
  if (!station) {
@@ -704,16 +1003,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
704
1003
  }, [go, player, showControlResult]);
705
1004
  const setVolume = useCallback((volume) => {
706
1005
  const clamped = clampVolume(volume);
1006
+ const requestId = volumeRequestRef.current + 1;
1007
+ volumeRequestRef.current = requestId;
707
1008
  void player.setVolume(clamped).then(result => {
708
- if (result.ok) {
709
- updateSettings({ volume: clamped });
1009
+ if (result.ok && volumeRequestRef.current === requestId) {
1010
+ updateSettings({ volume: player.getState().volume });
710
1011
  }
711
1012
  showControlResult(result);
712
1013
  });
713
1014
  }, [player, showControlResult, updateSettings]);
714
1015
  const adjustVolume = useCallback((delta) => {
715
- setVolume((player.getState().volume || library.settings.volume) + delta);
716
- }, [library.settings.volume, player, setVolume]);
1016
+ const requestId = volumeRequestRef.current + 1;
1017
+ volumeRequestRef.current = requestId;
1018
+ void player.adjustVolume(delta).then(result => {
1019
+ if (result.ok && volumeRequestRef.current === requestId) {
1020
+ updateSettings({ volume: player.getState().volume });
1021
+ }
1022
+ showControlResult(result);
1023
+ });
1024
+ }, [player, showControlResult, updateSettings]);
717
1025
  const toggleMute = useCallback(() => {
718
1026
  void player.toggleMute().then(showControlResult);
719
1027
  }, [player, showControlResult]);
@@ -751,6 +1059,21 @@ export function App({ store: providedStore, providers: providedProviders }) {
751
1059
  const enableNearbyLocation = !settingsRef.current.enableNearbyLocation;
752
1060
  updateSettings({ enableNearbyLocation });
753
1061
  setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
1062
+ if (enableNearbyLocation && screenRef.current === 'nearby') {
1063
+ setTimeout(() => void loadNearby(), 0);
1064
+ }
1065
+ else if (!enableNearbyLocation && screenRef.current === 'nearby') {
1066
+ const current = stationContextsRef.current.nearby;
1067
+ setStationContextFor('nearby', {
1068
+ ...current,
1069
+ subtitle: 'Location lookup off · showing the last loaded results'
1070
+ });
1071
+ }
1072
+ }, [loadNearby, setStationContextFor, updateSettings]);
1073
+ const toggleDirectoryVoting = useCallback(() => {
1074
+ const shareDirectoryVotes = !settingsRef.current.shareDirectoryVotes;
1075
+ updateSettings({ shareDirectoryVotes });
1076
+ setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
754
1077
  }, [updateSettings]);
755
1078
  const refreshAirPlayTargets = useCallback(async (announce = true) => {
756
1079
  try {
@@ -848,7 +1171,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
848
1171
  setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
849
1172
  }, [updateSettings]);
850
1173
  const toggleSetting = useCallback((key) => {
851
- const next = !settingsRef.current[key];
1174
+ const current = key === 'mouseSupport'
1175
+ ? settingsRef.current.mouseSupport !== false
1176
+ : Boolean(settingsRef.current[key]);
1177
+ const next = !current;
852
1178
  updateSettings({ [key]: next });
853
1179
  setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
854
1180
  }, [updateSettings]);
@@ -901,6 +1227,55 @@ export function App({ store: providedStore, providers: providedProviders }) {
901
1227
  openScreen(next.screen);
902
1228
  }
903
1229
  }, [openScreen, screen]);
1230
+ const handleUpdateCommand = useCallback(async () => {
1231
+ const updateCheck = library.updateCheck;
1232
+ if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
1233
+ setMessage('Checking for updates...');
1234
+ const latest = await refreshUpdateCheck();
1235
+ if (latest.error) {
1236
+ setMessage(`Update check failed: ${latest.error}`);
1237
+ return;
1238
+ }
1239
+ }
1240
+ const command = updateCommandForInstall();
1241
+ const copied = copyToClipboard(command.command);
1242
+ const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
1243
+ const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
1244
+ const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
1245
+ setMessage(`${prefix}${copied ? 'Copied' : 'Run'} ${method} update: ${command.command}`);
1246
+ }, [library.updateCheck, refreshUpdateCheck, store]);
1247
+ const updateFromSettings = useCallback(async () => {
1248
+ if (installingUpdateRef.current) {
1249
+ setMessage('Update install already running.');
1250
+ return;
1251
+ }
1252
+ const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
1253
+ if (!currentUpdateCheck?.updateAvailable) {
1254
+ setMessage('Checking for updates...');
1255
+ const latest = await refreshUpdateCheck();
1256
+ if (latest.error) {
1257
+ setMessage(`Update check failed: ${latest.error}`);
1258
+ return;
1259
+ }
1260
+ if (latest.updateAvailable && latest.latestVersion) {
1261
+ setMessage(`Update available: v${latest.latestVersion}. Press Enter on Install update.`);
1262
+ return;
1263
+ }
1264
+ setMessage(`RadioCLI is up to date at v${installedVersion}.`);
1265
+ return;
1266
+ }
1267
+ const command = updateCommandForInstall();
1268
+ installingUpdateRef.current = true;
1269
+ setMessage(`Installing update with ${command.method === 'homebrew' ? 'Homebrew' : 'npm'}...`);
1270
+ const result = await installUpdate(command.command);
1271
+ installingUpdateRef.current = false;
1272
+ if (result.ok) {
1273
+ setMessage(`Update installed. Restart RadioCLI to use the new version.`);
1274
+ return;
1275
+ }
1276
+ const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
1277
+ setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
1278
+ }, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
904
1279
  const executeCommand = useCommandExecutor({
905
1280
  beginLearningTransportKey,
906
1281
  countries,
@@ -926,6 +1301,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
926
1301
  store,
927
1302
  toggleFavorite,
928
1303
  toggleMute,
1304
+ updateCommand: handleUpdateCommand,
929
1305
  updateSettings
930
1306
  });
931
1307
  const playAdjacent = useCallback((direction) => {
@@ -961,6 +1337,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
961
1337
  capturingTransportAction,
962
1338
  commandMode,
963
1339
  commandText,
1340
+ confirmCtrlCExit,
964
1341
  currentItemCount,
965
1342
  cycleDisplayColor,
966
1343
  cycleAudioOutput,
@@ -1017,24 +1394,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
1017
1394
  togglePause,
1018
1395
  toggleSetting,
1019
1396
  toggleNearbyLocation,
1397
+ toggleDirectoryVoting,
1020
1398
  toggleRadioGarden,
1021
- toggleSkipBrokenStreams
1399
+ toggleSkipBrokenStreams,
1400
+ updateFromSettings
1022
1401
  });
1023
1402
  function currentItemCount(currentScreen) {
1024
1403
  return itemCountsRef.current[currentScreen] ?? 0;
1025
1404
  }
1026
- const hasTopTabs = !layout.compact;
1027
- const globalFooter = playback.backend === 'ffplay'
1028
- ? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · ? help · q quit'
1029
- : playback.backend === 'airplay'
1030
- ? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · ? help · q quit'
1031
- : '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit';
1032
- const playbackFooter = playbackFooterText({
1033
- station: playingStation,
1034
- playback,
1405
+ const hasTopTabs = layout.mode === 'full';
1406
+ const globalFooter = '←/→ tabs · F7/F9 or ,/. station · t/v display · ? help · q quit';
1407
+ const playbackFooter = screen === 'now-playing' ? null : playbackFooterText({
1408
+ station: footerStation,
1409
+ playback: footerPlayback,
1035
1410
  metadata: nowPlaying,
1036
1411
  queue: playbackQueueRef.current,
1037
- favorite: store.isFavorite(playingStation),
1412
+ favorite: store.isFavorite(footerStation),
1038
1413
  sleepLabel,
1039
1414
  width: frameWidth,
1040
1415
  spinnerFrame
@@ -1049,7 +1424,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
1049
1424
  playbackBackend: playback.backend,
1050
1425
  screen
1051
1426
  });
1052
- return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), _jsx(Box, { height: 1, children: message ? _jsx(Text, { color: themeAccent(theme), children: truncate(message, frameWidth) }) : null })] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(Text, { color: themeAccent(theme), children: footerMessage ? truncate(footerMessage, frameWidth) : ' ' }), playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }) }));
1427
+ const pageFooterOwnsCompactRow = Boolean(commandMode ||
1428
+ capturingTransportAction ||
1429
+ editingCountryFilter ||
1430
+ (screen === 'search' && editingSearch) ||
1431
+ screen === 'airplay-code');
1432
+ const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
1433
+ const compactFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow ? pageFooter : playbackFooter ?? pageFooter);
1434
+ const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
1435
+ ? pageFooter
1436
+ : footerPlayback.state === 'loading' && playbackFooter
1437
+ ? playbackFooter
1438
+ : hasActiveMicroPlayback
1439
+ ? microPlaybackControlsText(playback.backend)
1440
+ : pageFooter);
1441
+ const compactGlobalFooter = `←/→ tabs · ? help · q quit · v${installedVersion}`;
1442
+ const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
1443
+ return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: truncate(statusRow.text, frameWidth) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: truncate(pageFooter, frameWidth) }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: truncate(globalFooter, Math.max(1, frameWidth - installedVersion.length - 3)) }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { color: textDim, children: ["v", installedVersion] })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || message ? themeAccent(theme) : textMuted, children: truncate(layout.mode === 'micro' ? microFooter : compactFooter, frameWidth) }), layout.footerRows > 1 ? _jsx(Text, { color: textDim, children: truncate(compactGlobalFooter, frameWidth) }) : null] })) })] }) }));
1053
1444
  }
1054
1445
  function buildLibraryStations(library) {
1055
1446
  const stations = [];
@@ -1080,6 +1471,31 @@ function exploreCursorLocation(cursor) {
1080
1471
  source: 'explore cursor'
1081
1472
  };
1082
1473
  }
1474
+ function appendUniqueStations(current, page) {
1475
+ const stations = [...current];
1476
+ const seen = new Set(current.map(stationKey));
1477
+ for (const station of page) {
1478
+ const key = stationKey(station);
1479
+ if (!seen.has(key)) {
1480
+ seen.add(key);
1481
+ stations.push(station);
1482
+ }
1483
+ }
1484
+ return stations;
1485
+ }
1486
+ function formatCountryStationsSubtitle(country, loaded, hasMore) {
1487
+ const total = country.stationCount.toLocaleString();
1488
+ const loadedLabel = loaded.toLocaleString();
1489
+ return hasMore
1490
+ ? `${country.code} · ${loadedLabel} of ${total} listed stations loaded`
1491
+ : `${country.code} · ${loadedLabel} of ${total} listed stations`;
1492
+ }
1493
+ function formatSearchSubtitle(loaded, hasMore) {
1494
+ const count = loaded.toLocaleString();
1495
+ return hasMore
1496
+ ? `Matches across enabled public station directories · ${count}+ loaded`
1497
+ : `Matches across enabled public station directories · ${count} loaded`;
1498
+ }
1083
1499
  function formatExploreSubtitle(cursor, stations) {
1084
1500
  if (stations.length === 0) {
1085
1501
  return `No geotagged stations near ${formatExploreCursor(cursor)}`;
@@ -1138,3 +1554,22 @@ function audioOutputSwitchLabel(output, backends) {
1138
1554
  function librarySubtitle(library) {
1139
1555
  return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
1140
1556
  }
1557
+ function mouseVisibleRows(screen, layout) {
1558
+ if (screen === 'help')
1559
+ return Math.max(3, layout.contentRows - 4);
1560
+ if (layout.compact)
1561
+ return Math.max(1, layout.contentRows - 3);
1562
+ if (screen === 'countries')
1563
+ return layout.countryRows;
1564
+ if (screen === 'map')
1565
+ return layout.mapCountryRows;
1566
+ if (screen === 'search')
1567
+ return Math.max(1, layout.contentRows - 8);
1568
+ if (screen === 'settings')
1569
+ return Math.max(5, layout.contentRows - 10);
1570
+ if (screen === 'airplay-settings')
1571
+ return Math.max(1, layout.contentRows - 9);
1572
+ if (['stations', 'nearby', 'explore', 'library'].includes(screen))
1573
+ return layout.stationRows;
1574
+ return layout.contentRows;
1575
+ }