@ciphore/radiocli 0.2.0 → 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 (57) hide show
  1. package/CHANGELOG.md +44 -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 +36 -2
  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 +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  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 +9 -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 +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  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 +93 -21
  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 +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
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,9 +13,9 @@ 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';
@@ -24,12 +24,16 @@ import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
24
24
  import { copyToClipboard, openExternal } from './system-actions.js';
25
25
  import { appVersion } from '../version.js';
26
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';
27
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';
28
30
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
29
31
  const LIVE_RECEIVER_PULSE_MS = 80;
32
+ const SKYLINE_RECEIVER_PULSE_MS = 120;
30
33
  const AMBIENT_RECEIVER_PULSE_MS = 140;
31
34
  const LOADING_SPINNER_MS = 120;
32
35
  const VISUALIZER_MESSAGE_MS = 4500;
36
+ const LISTENING_HEARTBEAT_MS = 30_000;
33
37
  const COUNTRY_STATIONS_PAGE_SIZE = 120;
34
38
  const COUNTRY_STATIONS_LOAD_AHEAD = 12;
35
39
  const SEARCH_RESULTS_PAGE_SIZE = 90;
@@ -38,7 +42,8 @@ const settingToggleLabel = {
38
42
  resumeOnLaunch: 'Resume on launch',
39
43
  transparentBackground: 'Transparent background',
40
44
  asciiMode: 'ASCII-safe display',
41
- reduceMotion: 'Reduce motion'
45
+ reduceMotion: 'Reduce motion',
46
+ mouseSupport: 'Mouse and trackpad scrolling'
42
47
  };
43
48
  export function App({ store: providedStore, providers: providedProviders }) {
44
49
  const { exit } = useApp();
@@ -68,6 +73,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
68
73
  const [searchQuery, setSearchQuery] = useState('');
69
74
  const [editingSearch, setEditingSearch] = useState(true);
70
75
  const [playingStation, setPlayingStation] = useState(null);
76
+ const [tuningStation, setTuningStation] = useState(null);
71
77
  const [nowPlaying, setNowPlaying] = useState(null);
72
78
  const [location, setLocation] = useState(null);
73
79
  const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
@@ -102,7 +108,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
102
108
  const exploreMoveTimerRef = useRef(null);
103
109
  const transientMessageTimerRef = useRef(null);
104
110
  const transientFooterMessageTimerRef = useRef(null);
111
+ const exitConfirmationUntilRef = useRef(0);
105
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);
106
121
  const theme = library.settings.theme;
107
122
  const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
108
123
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
@@ -153,7 +168,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
153
168
  'airplay-settings': 0,
154
169
  'airplay-code': 1,
155
170
  settings: settingsItems.length,
156
- help: 0
171
+ help: helpItemCount
157
172
  });
158
173
  itemCountsRef.current = {
159
174
  home: homeItems.length,
@@ -169,37 +184,75 @@ export function App({ store: providedStore, providers: providedProviders }) {
169
184
  'airplay-settings': availableAirPlayDevices.length,
170
185
  'airplay-code': 1,
171
186
  settings: settingsItems.length,
172
- help: 0
187
+ help: helpItemCount
173
188
  };
174
189
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
175
190
  displayStationsRef.current = displayStations;
176
191
  const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
177
- const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
178
- 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);
179
197
  const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
180
198
  const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
181
199
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
182
200
  const layout = computeTerminalLayout(columns, rows, footerRows);
183
- 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);
184
206
  useEffect(() => player.onChange(setPlayback), [player]);
185
207
  const playingStationRef = useRef(null);
186
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]);
187
236
  useEffect(() => player.onMetadata(metadata => {
188
237
  setNowPlaying(metadata);
189
238
  const station = playingStationRef.current;
190
239
  if (station && metadata.title) {
240
+ const trackKey = `${stationKey(station)}:${metadata.title}`;
241
+ if (lastRecordedTrackRef.current === trackKey)
242
+ return;
243
+ lastRecordedTrackRef.current = trackKey;
191
244
  setLibrary(store.recordTrack(station, metadata.title));
192
245
  }
193
246
  }), [player, store]);
194
247
  useEffect(() => {
195
- if (!stdout.isTTY) {
248
+ if (!stdout.isTTY || !mouseReportingActive) {
196
249
  return;
197
250
  }
198
251
  stdout.write(enableMouseReporting);
199
252
  return () => {
200
253
  stdout.write(disableMouseReporting);
201
254
  };
202
- }, [stdout]);
255
+ }, [mouseReportingActive, stdout]);
203
256
  useEffect(() => {
204
257
  selectedByScreenRef.current[screen] = selected;
205
258
  if (renderedStationContextKey) {
@@ -225,12 +278,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
225
278
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
226
279
  return;
227
280
  }
228
- 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;
229
286
  const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
230
287
  return () => clearInterval(timer);
231
288
  }, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
232
289
  useEffect(() => {
233
- if (playback.state !== 'loading' ||
290
+ if (footerPlayback.state !== 'loading' ||
234
291
  library.settings.reduceMotion ||
235
292
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
236
293
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
@@ -239,9 +296,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
239
296
  }
240
297
  const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
241
298
  return () => clearInterval(timer);
242
- }, [library.settings.reduceMotion, playback.state]);
299
+ }, [footerPlayback.state, library.settings.reduceMotion]);
243
300
  useEffect(() => {
244
- 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;
245
306
  setLoadingCountries(true);
246
307
  providers
247
308
  .countries()
@@ -260,6 +321,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
260
321
  if (transientFooterMessageTimerRef.current) {
261
322
  clearTimeout(transientFooterMessageTimerRef.current);
262
323
  }
324
+ if (skipBrokenTimerRef.current) {
325
+ clearTimeout(skipBrokenTimerRef.current);
326
+ }
263
327
  }, []);
264
328
  useEffect(() => {
265
329
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
@@ -270,12 +334,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
270
334
  }
271
335
  const delayMs = sleepUntil - Date.now();
272
336
  if (delayMs <= 0) {
337
+ tuneRequestRef.current += 1;
273
338
  setLibrary(store.finishActiveListeningSession());
274
339
  void player.stop();
275
340
  setSleepUntil(null);
276
341
  return;
277
342
  }
278
343
  const timer = setTimeout(() => {
344
+ tuneRequestRef.current += 1;
279
345
  setLibrary(store.finishActiveListeningSession());
280
346
  void player.stop();
281
347
  setSleepUntil(null);
@@ -360,14 +426,20 @@ export function App({ store: providedStore, providers: providedProviders }) {
360
426
  setMessage(`Learned ${mediaActionLabel(action)} key.`);
361
427
  }, [updateMediaKeys]);
362
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;
363
435
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
364
- const remembered = selectedByScreenRef.current[next] ?? 0;
436
+ const remembered = selectedByScreenRef.current[destination] ?? 0;
365
437
  const nextSelection = options.resetSelection ? 0 : remembered;
366
- if (next === 'now-playing' && screenRef.current !== 'now-playing') {
438
+ if (destination === 'now-playing' && screenRef.current !== 'now-playing') {
367
439
  setPulse(0);
368
440
  }
369
- setScreen(next);
370
- setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
441
+ setScreen(destination);
442
+ setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
371
443
  if (options.clearMessage !== false) {
372
444
  setMessage(null);
373
445
  }
@@ -382,9 +454,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
382
454
  }
383
455
  }, [openAirPlayCode, playback.backend, playback.message]);
384
456
  const shutdown = useCallback(() => {
457
+ tuneRequestRef.current += 1;
385
458
  store.finishActiveListeningSession();
386
459
  player.stop().finally(exit);
387
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]);
388
473
  const showStationContext = useCallback((context, next = 'stations', options = {}) => {
389
474
  setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
390
475
  go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
@@ -467,11 +552,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
467
552
  void loadExploreAt(next, { resetSelection: true, clearMessage: false });
468
553
  }, [frameWidth, layout, loadExploreAt]);
469
554
  const loadCountry = useCallback(async (country) => {
555
+ const requestId = countryRequestRef.current + 1;
556
+ countryRequestRef.current = requestId;
470
557
  setLoadingStations(true);
471
558
  setMessage(null);
472
559
  countryPageRequestRef.current = null;
473
560
  try {
474
561
  const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
562
+ if (requestId !== countryRequestRef.current)
563
+ return;
475
564
  showStationContext({
476
565
  title: country.name,
477
566
  subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
@@ -481,10 +570,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
481
570
  }, 'stations');
482
571
  }
483
572
  catch (error) {
573
+ if (requestId !== countryRequestRef.current)
574
+ return;
484
575
  setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`);
485
576
  }
486
577
  finally {
487
- setLoadingStations(false);
578
+ if (requestId === countryRequestRef.current)
579
+ setLoadingStations(false);
488
580
  }
489
581
  }, [providers, showStationContext]);
490
582
  const loadMoreCountryStations = useCallback(async () => {
@@ -507,7 +599,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
507
599
  return;
508
600
  }
509
601
  const stations = appendUniqueStations(latest.stations, page);
510
- const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount;
602
+ const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE &&
603
+ stations.length > latest.stations.length &&
604
+ stations.length < country.stationCount;
511
605
  setStationContextFor('stations', {
512
606
  ...latest,
513
607
  subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
@@ -547,6 +641,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
547
641
  setMessage('Enter a station, genre, language, or place.');
548
642
  return;
549
643
  }
644
+ const requestId = searchRequestRef.current + 1;
645
+ searchRequestRef.current = requestId;
550
646
  setLoadingStations(true);
551
647
  setMessage(null);
552
648
  try {
@@ -557,6 +653,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
557
653
  language: filters.language ?? undefined,
558
654
  minBitrate: filters.minBitrate ?? undefined
559
655
  });
656
+ if (requestId !== searchRequestRef.current)
657
+ return;
560
658
  setStationContextFor('search', {
561
659
  title: `Search: ${query}`,
562
660
  subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
@@ -572,10 +670,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
572
670
  setEditingSearch(true);
573
671
  }
574
672
  catch (error) {
673
+ if (requestId !== searchRequestRef.current)
674
+ return;
575
675
  setMessage(error instanceof Error ? error.message : 'Search failed.');
576
676
  }
577
677
  finally {
578
- setLoadingStations(false);
678
+ if (requestId === searchRequestRef.current)
679
+ setLoadingStations(false);
579
680
  }
580
681
  }, [filters, providers, searchQuery, setStationContextFor, store]);
581
682
  const loadMoreSearchResults = useCallback(async () => {
@@ -660,6 +761,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
660
761
  }
661
762
  }, [searchQuery, store]);
662
763
  const loadNearby = useCallback(async () => {
764
+ const requestId = nearbyRequestRef.current + 1;
765
+ nearbyRequestRef.current = requestId;
663
766
  setLoadingStations(true);
664
767
  setMessage(null);
665
768
  go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
@@ -677,6 +780,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
677
780
  return;
678
781
  }
679
782
  const detected = location ?? (await providers.detectLocation());
783
+ if (requestId !== nearbyRequestRef.current)
784
+ return;
680
785
  setLocation(detected);
681
786
  if (!detected) {
682
787
  if (stationContextsRef.current.nearby.stations.length > 0) {
@@ -691,6 +796,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
691
796
  return;
692
797
  }
693
798
  const stations = await providers.nearby(detected, 90);
799
+ if (requestId !== nearbyRequestRef.current)
800
+ return;
694
801
  setStationContextFor('nearby', {
695
802
  title: 'Nearby',
696
803
  subtitle: `${[detected.city, detected.region, detected.country].filter(Boolean).join(', ')} · ${detected.source}`,
@@ -698,10 +805,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
698
805
  });
699
806
  }
700
807
  catch (error) {
808
+ if (requestId !== nearbyRequestRef.current)
809
+ return;
701
810
  setMessage(error instanceof Error ? error.message : 'Could not load nearby stations.');
702
811
  }
703
812
  finally {
704
- setLoadingStations(false);
813
+ if (requestId === nearbyRequestRef.current)
814
+ setLoadingStations(false);
705
815
  }
706
816
  }, [go, location, providers, setStationContextFor]);
707
817
  const queueFromCurrentList = useCallback((station) => {
@@ -735,14 +845,31 @@ export function App({ store: providedStore, providers: providedProviders }) {
735
845
  }
736
846
  }, []);
737
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
+ }
738
854
  const queue = options.queue ?? queueFromCurrentList(station);
855
+ setTuningStation(station);
739
856
  setNowPlaying(null);
740
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;
741
864
  const resolved = await providers.resolve(station);
865
+ if (requestId !== tuneRequestRef.current)
866
+ return;
742
867
  await player.play(station, resolved.url);
868
+ if (requestId !== tuneRequestRef.current)
869
+ return;
743
870
  setPlayingStation(station);
871
+ setTuningStation(null);
744
872
  playbackQueueRef.current = queue;
745
- store.startListeningSession(station);
746
873
  const nextLibrary = store.addRecent(station);
747
874
  if (screenRef.current === 'library') {
748
875
  const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
@@ -759,16 +886,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
759
886
  setMessage(null);
760
887
  }
761
888
  catch (error) {
889
+ if (requestId !== tuneRequestRef.current)
890
+ return;
762
891
  const message = error instanceof Error ? error.message : 'Could not tune station.';
763
892
  const currentList = queue.stations;
764
893
  const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
765
894
  const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
766
895
  if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
767
896
  setMessage(`${message} Skipping to ${nextStation.name}.`);
897
+ setTuningStation(nextStation);
768
898
  rememberQueueSelection(queue, currentIndex + 1);
769
- setTimeout(() => playStationRef.current(nextStation, { ...options, queue }), 250);
899
+ skipBrokenTimerRef.current = setTimeout(() => {
900
+ skipBrokenTimerRef.current = null;
901
+ playStationRef.current(nextStation, { ...options, queue });
902
+ }, 250);
770
903
  return;
771
904
  }
905
+ setTuningStation(null);
772
906
  setMessage(message);
773
907
  }
774
908
  }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
@@ -789,7 +923,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
789
923
  }
790
924
  }, [playStation, store]);
791
925
  playStationRef.current = playStation;
792
- const showTransientFooterMessage = useCallback((nextMessage) => {
926
+ const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
793
927
  if (transientFooterMessageTimerRef.current) {
794
928
  clearTimeout(transientFooterMessageTimerRef.current);
795
929
  }
@@ -797,8 +931,17 @@ export function App({ store: providedStore, providers: providedProviders }) {
797
931
  transientFooterMessageTimerRef.current = setTimeout(() => {
798
932
  setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
799
933
  transientFooterMessageTimerRef.current = null;
800
- }, VISUALIZER_MESSAGE_MS);
934
+ }, durationMs);
801
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]);
802
945
  const toggleFavorite = useCallback((station) => {
803
946
  if (!station) {
804
947
  setMessage('Select or play a station before pressing f.');
@@ -813,7 +956,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
813
956
  else {
814
957
  setMessage(favoriteMessage);
815
958
  }
816
- if (!wasFavorite) {
959
+ if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
817
960
  // Best-effort upvote back to the directory; never blocks favoriting.
818
961
  void providers.vote(station);
819
962
  }
@@ -828,8 +971,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
828
971
  setMessage('This station has no homepage.');
829
972
  return;
830
973
  }
831
- openExternal(station.homepage);
832
- 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.');
833
977
  }, []);
834
978
  const copyStationUrl = useCallback(async (station) => {
835
979
  if (!station) {
@@ -859,16 +1003,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
859
1003
  }, [go, player, showControlResult]);
860
1004
  const setVolume = useCallback((volume) => {
861
1005
  const clamped = clampVolume(volume);
1006
+ const requestId = volumeRequestRef.current + 1;
1007
+ volumeRequestRef.current = requestId;
862
1008
  void player.setVolume(clamped).then(result => {
863
- if (result.ok) {
864
- updateSettings({ volume: clamped });
1009
+ if (result.ok && volumeRequestRef.current === requestId) {
1010
+ updateSettings({ volume: player.getState().volume });
865
1011
  }
866
1012
  showControlResult(result);
867
1013
  });
868
1014
  }, [player, showControlResult, updateSettings]);
869
1015
  const adjustVolume = useCallback((delta) => {
870
- setVolume((player.getState().volume || library.settings.volume) + delta);
871
- }, [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]);
872
1025
  const toggleMute = useCallback(() => {
873
1026
  void player.toggleMute().then(showControlResult);
874
1027
  }, [player, showControlResult]);
@@ -906,6 +1059,21 @@ export function App({ store: providedStore, providers: providedProviders }) {
906
1059
  const enableNearbyLocation = !settingsRef.current.enableNearbyLocation;
907
1060
  updateSettings({ enableNearbyLocation });
908
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'}.`);
909
1077
  }, [updateSettings]);
910
1078
  const refreshAirPlayTargets = useCallback(async (announce = true) => {
911
1079
  try {
@@ -1003,7 +1171,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
1003
1171
  setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
1004
1172
  }, [updateSettings]);
1005
1173
  const toggleSetting = useCallback((key) => {
1006
- const next = !settingsRef.current[key];
1174
+ const current = key === 'mouseSupport'
1175
+ ? settingsRef.current.mouseSupport !== false
1176
+ : Boolean(settingsRef.current[key]);
1177
+ const next = !current;
1007
1178
  updateSettings({ [key]: next });
1008
1179
  setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
1009
1180
  }, [updateSettings]);
@@ -1166,6 +1337,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1166
1337
  capturingTransportAction,
1167
1338
  commandMode,
1168
1339
  commandText,
1340
+ confirmCtrlCExit,
1169
1341
  currentItemCount,
1170
1342
  cycleDisplayColor,
1171
1343
  cycleAudioOutput,
@@ -1222,6 +1394,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1222
1394
  togglePause,
1223
1395
  toggleSetting,
1224
1396
  toggleNearbyLocation,
1397
+ toggleDirectoryVoting,
1225
1398
  toggleRadioGarden,
1226
1399
  toggleSkipBrokenStreams,
1227
1400
  updateFromSettings
@@ -1229,18 +1402,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
1229
1402
  function currentItemCount(currentScreen) {
1230
1403
  return itemCountsRef.current[currentScreen] ?? 0;
1231
1404
  }
1232
- const hasTopTabs = !layout.compact;
1233
- const globalFooter = playback.backend === 'ffplay'
1234
- ? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · ? help · q quit'
1235
- : playback.backend === 'airplay'
1236
- ? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · ? help · q quit'
1237
- : '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit';
1238
- const playbackFooter = playbackFooterText({
1239
- station: playingStation,
1240
- 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,
1241
1410
  metadata: nowPlaying,
1242
1411
  queue: playbackQueueRef.current,
1243
- favorite: store.isFavorite(playingStation),
1412
+ favorite: store.isFavorite(footerStation),
1244
1413
  sleepLabel,
1245
1414
  width: frameWidth,
1246
1415
  spinnerFrame
@@ -1255,7 +1424,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
1255
1424
  playbackBackend: playback.backend,
1256
1425
  screen
1257
1426
  });
1258
- 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, 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: 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, updateCheck: library.updateCheck }), _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) }), _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] })] })] })] }) }));
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] })) })] }) }));
1259
1444
  }
1260
1445
  function buildLibraryStations(library) {
1261
1446
  const stations = [];
@@ -1369,3 +1554,22 @@ function audioOutputSwitchLabel(output, backends) {
1369
1554
  function librarySubtitle(library) {
1370
1555
  return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
1371
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
+ }