@ciphore/radiocli 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/App.js CHANGED
@@ -22,12 +22,18 @@ 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';
25
27
  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
28
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
27
29
  const LIVE_RECEIVER_PULSE_MS = 80;
28
30
  const AMBIENT_RECEIVER_PULSE_MS = 140;
29
31
  const LOADING_SPINNER_MS = 120;
30
32
  const VISUALIZER_MESSAGE_MS = 4500;
33
+ const COUNTRY_STATIONS_PAGE_SIZE = 120;
34
+ const COUNTRY_STATIONS_LOAD_AHEAD = 12;
35
+ const SEARCH_RESULTS_PAGE_SIZE = 90;
36
+ const SEARCH_RESULTS_LOAD_AHEAD = 12;
31
37
  const settingToggleLabel = {
32
38
  resumeOnLaunch: 'Resume on launch',
33
39
  transparentBackground: 'Transparent background',
@@ -41,6 +47,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
41
47
  const { columns, rows } = useWindowSize();
42
48
  const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
43
49
  const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
50
+ const installedVersion = useMemo(() => appVersion(), []);
44
51
  const [library, setLibrary] = useState(() => store.snapshot());
45
52
  const settingsRef = useRef(library.settings);
46
53
  settingsRef.current = library.settings;
@@ -51,6 +58,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
51
58
  const [screen, setScreen] = useState('home');
52
59
  const [selected, setSelected] = useState(0);
53
60
  const [message, setMessage] = useState(null);
61
+ const [footerMessage, setFooterMessage] = useState(null);
54
62
  const [countries, setCountries] = useState([]);
55
63
  const [countryFilter, setCountryFilter] = useState('');
56
64
  const [editingCountryFilter, setEditingCountryFilter] = useState(false);
@@ -73,9 +81,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
73
81
  const [sleepUntil, setSleepUntil] = useState(null);
74
82
  const [showDiagnostics, setShowDiagnostics] = useState(false);
75
83
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
84
+ const announcedUpdateRef = useRef(false);
85
+ const installingUpdateRef = useRef(false);
76
86
  const displayStationsRef = useRef([]);
77
87
  const playbackQueueRef = useRef(null);
78
88
  const lastRawTransportAtRef = useRef(0);
89
+ const loadingStationsRef = useRef(false);
90
+ const countryPageRequestRef = useRef(null);
91
+ const searchPageRequestRef = useRef(null);
79
92
  const playStationRef = useRef(() => undefined);
80
93
  const screenRef = useRef(screen);
81
94
  const selectedRef = useRef(selected);
@@ -88,6 +101,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
88
101
  const exploreRequestRef = useRef(0);
89
102
  const exploreMoveTimerRef = useRef(null);
90
103
  const transientMessageTimerRef = useRef(null);
104
+ const transientFooterMessageTimerRef = useRef(null);
91
105
  const receiverPulseSnapshotRef = useRef(null);
92
106
  const theme = library.settings.theme;
93
107
  const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
@@ -112,6 +126,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
112
126
  }), [library, libraryStations, stationContexts]);
113
127
  screenRef.current = screen;
114
128
  selectedRef.current = selected;
129
+ loadingStationsRef.current = loadingStations;
115
130
  stationContextsRef.current = activeStationContexts;
116
131
  exploreCursorRef.current = exploreCursor;
117
132
  const renderedStationContextKey = stationContextKeyForScreen(screen);
@@ -160,10 +175,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
160
175
  displayStationsRef.current = displayStations;
161
176
  const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
162
177
  const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
178
+ const footerRows = showPlaybackFooter ? 4 : 3;
163
179
  const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
164
180
  const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
165
181
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
166
- const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
182
+ const layout = computeTerminalLayout(columns, rows, footerRows);
167
183
  const frameWidth = Math.max(40, layout.columns - 2);
168
184
  useEffect(() => player.onChange(setPlayback), [player]);
169
185
  const playingStationRef = useRef(null);
@@ -176,14 +192,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
176
192
  }
177
193
  }), [player, store]);
178
194
  useEffect(() => {
179
- if (screen !== 'explore' || layout.compact || !stdout.isTTY) {
195
+ if (!stdout.isTTY) {
180
196
  return;
181
197
  }
182
198
  stdout.write(enableMouseReporting);
183
199
  return () => {
184
200
  stdout.write(disableMouseReporting);
185
201
  };
186
- }, [layout.compact, screen, stdout]);
202
+ }, [stdout]);
187
203
  useEffect(() => {
188
204
  selectedByScreenRef.current[screen] = selected;
189
205
  if (renderedStationContextKey) {
@@ -241,6 +257,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
241
257
  if (transientMessageTimerRef.current) {
242
258
  clearTimeout(transientMessageTimerRef.current);
243
259
  }
260
+ if (transientFooterMessageTimerRef.current) {
261
+ clearTimeout(transientFooterMessageTimerRef.current);
262
+ }
244
263
  }, []);
245
264
  useEffect(() => {
246
265
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
@@ -277,6 +296,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
277
296
  useEffect(() => {
278
297
  refreshProviderHealth();
279
298
  }, [refreshProviderHealth]);
299
+ const refreshUpdateCheck = useCallback(async () => {
300
+ const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
301
+ setLibrary(store.updateCheckState(updateCheck));
302
+ return updateCheck;
303
+ }, [installedVersion, store]);
304
+ useEffect(() => {
305
+ if (!shouldCheckForUpdate(library.updateCheck)) {
306
+ return;
307
+ }
308
+ let cancelled = false;
309
+ void refreshUpdateCheck().then(updateCheck => {
310
+ if (cancelled) {
311
+ return;
312
+ }
313
+ if (updateCheck.updateAvailable && updateCheck.latestVersion && !announcedUpdateRef.current) {
314
+ announcedUpdateRef.current = true;
315
+ setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
316
+ }
317
+ });
318
+ return () => {
319
+ cancelled = true;
320
+ };
321
+ }, [library.updateCheck, refreshUpdateCheck]);
280
322
  const setStationContextFor = useCallback((key, context) => {
281
323
  setStationContexts(current => ({ ...current, [key]: context }));
282
324
  }, []);
@@ -321,6 +363,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
321
363
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
322
364
  const remembered = selectedByScreenRef.current[next] ?? 0;
323
365
  const nextSelection = options.resetSelection ? 0 : remembered;
366
+ if (next === 'now-playing' && screenRef.current !== 'now-playing') {
367
+ setPulse(0);
368
+ }
324
369
  setScreen(next);
325
370
  setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
326
371
  if (options.clearMessage !== false) {
@@ -424,12 +469,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
424
469
  const loadCountry = useCallback(async (country) => {
425
470
  setLoadingStations(true);
426
471
  setMessage(null);
472
+ countryPageRequestRef.current = null;
427
473
  try {
428
- const stations = await providers.byCountry(country.code, 120);
474
+ const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
429
475
  showStationContext({
430
476
  title: country.name,
431
- subtitle: `${country.code} · ${country.stationCount.toLocaleString()} listed stations`,
432
- stations
477
+ subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
478
+ stations,
479
+ country,
480
+ hasMore: stations.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount
433
481
  }, 'stations');
434
482
  }
435
483
  catch (error) {
@@ -439,6 +487,61 @@ export function App({ store: providedStore, providers: providedProviders }) {
439
487
  setLoadingStations(false);
440
488
  }
441
489
  }, [providers, showStationContext]);
490
+ const loadMoreCountryStations = useCallback(async () => {
491
+ const context = stationContextsRef.current.stations;
492
+ const country = context.country;
493
+ if (!country || !context.hasMore || loadingStationsRef.current) {
494
+ return;
495
+ }
496
+ const offset = context.stations.length;
497
+ const requestKey = `${country.code}:${offset}`;
498
+ if (countryPageRequestRef.current === requestKey) {
499
+ return;
500
+ }
501
+ countryPageRequestRef.current = requestKey;
502
+ setLoadingStations(true);
503
+ try {
504
+ const page = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset);
505
+ const latest = stationContextsRef.current.stations;
506
+ if (latest.country?.code !== country.code || latest.stations.length !== offset) {
507
+ return;
508
+ }
509
+ const stations = appendUniqueStations(latest.stations, page);
510
+ const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount;
511
+ setStationContextFor('stations', {
512
+ ...latest,
513
+ subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
514
+ stations,
515
+ hasMore
516
+ });
517
+ }
518
+ catch (error) {
519
+ if (stationContextsRef.current.stations.country?.code === country.code) {
520
+ setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
521
+ }
522
+ }
523
+ finally {
524
+ if (countryPageRequestRef.current === requestKey) {
525
+ countryPageRequestRef.current = null;
526
+ setLoadingStations(false);
527
+ }
528
+ }
529
+ }, [providers, setStationContextFor]);
530
+ useEffect(() => {
531
+ if (screen === 'stations' &&
532
+ stationContexts.stations.country &&
533
+ stationContexts.stations.hasMore &&
534
+ stationContexts.stations.stations.length - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
535
+ void loadMoreCountryStations();
536
+ }
537
+ }, [
538
+ loadMoreCountryStations,
539
+ screen,
540
+ selected,
541
+ stationContexts.stations.country,
542
+ stationContexts.stations.hasMore,
543
+ stationContexts.stations.stations.length
544
+ ]);
442
545
  const runSearch = useCallback(async (query = searchQuery) => {
443
546
  if (!query.trim()) {
444
547
  setMessage('Enter a station, genre, language, or place.');
@@ -448,15 +551,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
448
551
  setMessage(null);
449
552
  try {
450
553
  const stations = await providers.search(query, settingsRef.current, {
451
- limit: 90,
554
+ limit: SEARCH_RESULTS_PAGE_SIZE,
555
+ offset: 0,
452
556
  codec: filters.codec ?? undefined,
453
557
  language: filters.language ?? undefined,
454
558
  minBitrate: filters.minBitrate ?? undefined
455
559
  });
456
560
  setStationContextFor('search', {
457
561
  title: `Search: ${query}`,
458
- subtitle: 'Matches across enabled public station directories',
459
- stations
562
+ subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
563
+ stations,
564
+ query: query.trim(),
565
+ hasMore: stations.length >= SEARCH_RESULTS_PAGE_SIZE
460
566
  });
461
567
  selectedByScreenRef.current.search = 0;
462
568
  lastSubmittedSearchRef.current = query.trim();
@@ -472,6 +578,65 @@ export function App({ store: providedStore, providers: providedProviders }) {
472
578
  setLoadingStations(false);
473
579
  }
474
580
  }, [filters, providers, searchQuery, setStationContextFor, store]);
581
+ const loadMoreSearchResults = useCallback(async () => {
582
+ const context = stationContextsRef.current.search;
583
+ const query = context.query;
584
+ if (!query || !context.hasMore || loadingStationsRef.current) {
585
+ return;
586
+ }
587
+ const offset = context.stations.length;
588
+ const requestKey = `${query}:${offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
589
+ if (searchPageRequestRef.current === requestKey) {
590
+ return;
591
+ }
592
+ searchPageRequestRef.current = requestKey;
593
+ setLoadingStations(true);
594
+ try {
595
+ const page = await providers.search(query, settingsRef.current, {
596
+ limit: SEARCH_RESULTS_PAGE_SIZE,
597
+ offset,
598
+ codec: filters.codec ?? undefined,
599
+ language: filters.language ?? undefined,
600
+ minBitrate: filters.minBitrate ?? undefined
601
+ });
602
+ const latest = stationContextsRef.current.search;
603
+ if (latest.query !== query || latest.stations.length !== offset) {
604
+ return;
605
+ }
606
+ const stations = appendUniqueStations(latest.stations, page);
607
+ const hasMore = page.length >= SEARCH_RESULTS_PAGE_SIZE && stations.length > latest.stations.length;
608
+ setStationContextFor('search', {
609
+ ...latest,
610
+ subtitle: formatSearchSubtitle(stations.length, hasMore),
611
+ stations,
612
+ hasMore
613
+ });
614
+ }
615
+ catch (error) {
616
+ if (stationContextsRef.current.search.query === query) {
617
+ setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
618
+ }
619
+ }
620
+ finally {
621
+ if (searchPageRequestRef.current === requestKey) {
622
+ searchPageRequestRef.current = null;
623
+ setLoadingStations(false);
624
+ }
625
+ }
626
+ }, [filters, providers, setStationContextFor]);
627
+ useEffect(() => {
628
+ if (screen === 'search' &&
629
+ stationContexts.search.hasMore &&
630
+ stationContexts.search.stations.length - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
631
+ void loadMoreSearchResults();
632
+ }
633
+ }, [
634
+ loadMoreSearchResults,
635
+ screen,
636
+ selected,
637
+ stationContexts.search.hasMore,
638
+ stationContexts.search.stations.length
639
+ ]);
475
640
  const recallSearchHistory = useCallback((direction) => {
476
641
  const history = store.snapshot().searchHistory;
477
642
  if (history.length === 0) {
@@ -500,6 +665,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
500
665
  go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
501
666
  try {
502
667
  if (!settingsRef.current.enableNearbyLocation) {
668
+ if (stationContextsRef.current.nearby.stations.length > 0) {
669
+ setMessage('Nearby location lookup is off. Showing the last nearby station list.');
670
+ return;
671
+ }
503
672
  setStationContextFor('nearby', {
504
673
  title: 'Nearby',
505
674
  subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
@@ -510,6 +679,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
510
679
  const detected = location ?? (await providers.detectLocation());
511
680
  setLocation(detected);
512
681
  if (!detected) {
682
+ if (stationContextsRef.current.nearby.stations.length > 0) {
683
+ setMessage('Location detection is unavailable. Showing the last nearby station list.');
684
+ return;
685
+ }
513
686
  setStationContextFor('nearby', {
514
687
  title: 'Nearby',
515
688
  subtitle: 'Location detection was unavailable',
@@ -563,7 +736,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
563
736
  }, []);
564
737
  const playStation = useCallback(async (station, options = {}) => {
565
738
  const queue = options.queue ?? queueFromCurrentList(station);
566
- setMessage(`Tuning ${station.name}...`);
567
739
  setNowPlaying(null);
568
740
  try {
569
741
  const resolved = await providers.resolve(station);
@@ -617,6 +789,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
617
789
  }
618
790
  }, [playStation, store]);
619
791
  playStationRef.current = playStation;
792
+ const showTransientFooterMessage = useCallback((nextMessage) => {
793
+ if (transientFooterMessageTimerRef.current) {
794
+ clearTimeout(transientFooterMessageTimerRef.current);
795
+ }
796
+ setFooterMessage(nextMessage);
797
+ transientFooterMessageTimerRef.current = setTimeout(() => {
798
+ setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
799
+ transientFooterMessageTimerRef.current = null;
800
+ }, VISUALIZER_MESSAGE_MS);
801
+ }, []);
620
802
  const toggleFavorite = useCallback((station) => {
621
803
  if (!station) {
622
804
  setMessage('Select or play a station before pressing f.');
@@ -624,12 +806,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
624
806
  }
625
807
  const wasFavorite = store.isFavorite(station);
626
808
  setLibrary(store.toggleFavorite(station));
627
- setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
809
+ const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
810
+ if (screenRef.current === 'library') {
811
+ showTransientFooterMessage(favoriteMessage);
812
+ }
813
+ else {
814
+ setMessage(favoriteMessage);
815
+ }
628
816
  if (!wasFavorite) {
629
817
  // Best-effort upvote back to the directory; never blocks favoriting.
630
818
  void providers.vote(station);
631
819
  }
632
- }, [providers, store]);
820
+ }, [providers, showTransientFooterMessage, store]);
633
821
  const showControlResult = useCallback((result) => {
634
822
  if (!result.ok && result.message) {
635
823
  setMessage(result.message);
@@ -868,6 +1056,55 @@ export function App({ store: providedStore, providers: providedProviders }) {
868
1056
  openScreen(next.screen);
869
1057
  }
870
1058
  }, [openScreen, screen]);
1059
+ const handleUpdateCommand = useCallback(async () => {
1060
+ const updateCheck = library.updateCheck;
1061
+ if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
1062
+ setMessage('Checking for updates...');
1063
+ const latest = await refreshUpdateCheck();
1064
+ if (latest.error) {
1065
+ setMessage(`Update check failed: ${latest.error}`);
1066
+ return;
1067
+ }
1068
+ }
1069
+ const command = updateCommandForInstall();
1070
+ const copied = copyToClipboard(command.command);
1071
+ const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
1072
+ const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
1073
+ const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
1074
+ setMessage(`${prefix}${copied ? 'Copied' : 'Run'} ${method} update: ${command.command}`);
1075
+ }, [library.updateCheck, refreshUpdateCheck, store]);
1076
+ const updateFromSettings = useCallback(async () => {
1077
+ if (installingUpdateRef.current) {
1078
+ setMessage('Update install already running.');
1079
+ return;
1080
+ }
1081
+ const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
1082
+ if (!currentUpdateCheck?.updateAvailable) {
1083
+ setMessage('Checking for updates...');
1084
+ const latest = await refreshUpdateCheck();
1085
+ if (latest.error) {
1086
+ setMessage(`Update check failed: ${latest.error}`);
1087
+ return;
1088
+ }
1089
+ if (latest.updateAvailable && latest.latestVersion) {
1090
+ setMessage(`Update available: v${latest.latestVersion}. Press Enter on Install update.`);
1091
+ return;
1092
+ }
1093
+ setMessage(`RadioCLI is up to date at v${installedVersion}.`);
1094
+ return;
1095
+ }
1096
+ const command = updateCommandForInstall();
1097
+ installingUpdateRef.current = true;
1098
+ setMessage(`Installing update with ${command.method === 'homebrew' ? 'Homebrew' : 'npm'}...`);
1099
+ const result = await installUpdate(command.command);
1100
+ installingUpdateRef.current = false;
1101
+ if (result.ok) {
1102
+ setMessage(`Update installed. Restart RadioCLI to use the new version.`);
1103
+ return;
1104
+ }
1105
+ const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
1106
+ setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
1107
+ }, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
871
1108
  const executeCommand = useCommandExecutor({
872
1109
  beginLearningTransportKey,
873
1110
  countries,
@@ -893,6 +1130,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
893
1130
  store,
894
1131
  toggleFavorite,
895
1132
  toggleMute,
1133
+ updateCommand: handleUpdateCommand,
896
1134
  updateSettings
897
1135
  });
898
1136
  const playAdjacent = useCallback((direction) => {
@@ -985,7 +1223,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
985
1223
  toggleSetting,
986
1224
  toggleNearbyLocation,
987
1225
  toggleRadioGarden,
988
- toggleSkipBrokenStreams
1226
+ toggleSkipBrokenStreams,
1227
+ updateFromSettings
989
1228
  });
990
1229
  function currentItemCount(currentScreen) {
991
1230
  return itemCountsRef.current[currentScreen] ?? 0;
@@ -1016,7 +1255,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1016
1255
  playbackBackend: playback.backend,
1017
1256
  screen
1018
1257
  });
1019
- 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.panel, children: [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) })] })] }) }));
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] })] })] })] }) }));
1020
1259
  }
1021
1260
  function buildLibraryStations(library) {
1022
1261
  const stations = [];
@@ -1047,6 +1286,31 @@ function exploreCursorLocation(cursor) {
1047
1286
  source: 'explore cursor'
1048
1287
  };
1049
1288
  }
1289
+ function appendUniqueStations(current, page) {
1290
+ const stations = [...current];
1291
+ const seen = new Set(current.map(stationKey));
1292
+ for (const station of page) {
1293
+ const key = stationKey(station);
1294
+ if (!seen.has(key)) {
1295
+ seen.add(key);
1296
+ stations.push(station);
1297
+ }
1298
+ }
1299
+ return stations;
1300
+ }
1301
+ function formatCountryStationsSubtitle(country, loaded, hasMore) {
1302
+ const total = country.stationCount.toLocaleString();
1303
+ const loadedLabel = loaded.toLocaleString();
1304
+ return hasMore
1305
+ ? `${country.code} · ${loadedLabel} of ${total} listed stations loaded`
1306
+ : `${country.code} · ${loadedLabel} of ${total} listed stations`;
1307
+ }
1308
+ function formatSearchSubtitle(loaded, hasMore) {
1309
+ const count = loaded.toLocaleString();
1310
+ return hasMore
1311
+ ? `Matches across enabled public station directories · ${count}+ loaded`
1312
+ : `Matches across enabled public station directories · ${count} loaded`;
1313
+ }
1050
1314
  function formatExploreSubtitle(cursor, stations) {
1051
1315
  if (stations.length === 0) {
1052
1316
  return `No geotagged stations near ${formatExploreCursor(cursor)}`;
@@ -15,7 +15,7 @@ import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
15
15
  import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
16
16
  import { selectedAirPlayDevice } from './airplay-settings.js';
17
17
  import { playbackBackendLabel } from '../player/backend-install.js';
18
- export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme }) {
18
+ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck }) {
19
19
  if (layout.compact) {
20
20
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: textMuted, children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: textMuted, children: ["Playback: ", playback.state, " \u00B7 ", playbackBackendLabel(playback.backend)] }), _jsx(Text, { color: textMuted, children: "q quit \u00B7 Ctrl+C always exits" })] }));
21
21
  }
@@ -44,7 +44,7 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
44
44
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
45
45
  }
46
46
  if (screen === 'settings') {
47
- return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
47
+ return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
48
48
  }
49
49
  if (screen === 'airplay-settings') {
50
50
  return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth }));
@@ -173,6 +173,9 @@ export function stationContextKeyForScreen(screen) {
173
173
  export function shouldAnimateReceiver(screen, playback) {
174
174
  return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
175
175
  }
176
+ export function shouldToggleNearbyLocationShortcut(input, screen) {
177
+ return input === 'l' && (screen === 'home' || screen === 'nearby' || screen === 'settings');
178
+ }
176
179
  export function nextReceiverPulse(pulse) {
177
180
  return pulse + 1;
178
181
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  export function Menu({ items, selected, keyFor, render }) {
4
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === selected) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
4
+ const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
5
+ return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
5
6
  }
6
7
  export function Pointer({ active }) {
7
8
  return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
@@ -12,12 +12,12 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
12
12
  const rowWidth = Math.max(42, width - 4);
13
13
  const nameWidth = Math.min(48, Math.max(18, Math.floor(rowWidth * 0.42)));
14
14
  const metaWidth = Math.max(12, rowWidth - nameWidth - 6);
15
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", stations.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station, index, active) => {
15
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", stations.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station, _index, active) => {
16
16
  const favorite = favorites.has(`${station.provider}:${station.id}`);
17
17
  const stationName = truncate(station.name, favorite ? Math.max(1, nameWidth - 2) : nameWidth);
18
18
  const titleWidth = nameWidth + 2;
19
19
  const titleUsed = stationName.length + (favorite ? 2 : 0);
20
20
  const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: textMuted, children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children: _jsxs(Text, { color: textMuted, children: ["#", window.start + index + 1, " \u00B7 ", truncate(stationTags(station), rowWidth - 8)] }) })) : null] }));
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: textMuted, children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children: _jsx(Text, { color: textMuted, children: truncate(stationTags(station), rowWidth - 4) }) })) : null] }));
22
22
  } })] }));
23
23
  }
@@ -5,7 +5,7 @@ import { useDisplay } from '../display-context.js';
5
5
  import { truncate } from '../format.js';
6
6
  export function TopTabs({ tabs, active, theme, width, rightLabel }) {
7
7
  const accent = themeAccent(theme);
8
- const { panel: panelBackground, ascii } = useDisplay();
8
+ const { app: background, ascii } = useDisplay();
9
9
  const box = ascii
10
10
  ? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
11
11
  : { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
@@ -20,7 +20,7 @@ export function TopTabs({ tabs, active, theme, width, rightLabel }) {
20
20
  const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
21
21
  const visibleTabsWidth = tabsWidth(visibleTabs);
22
22
  const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
23
- return (_jsxs(Box, { flexDirection: "column", backgroundColor: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: panelBackground, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: panelBackground, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
23
+ return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
24
24
  }
25
25
  function fitTabs(tabs, active, maxWidth) {
26
26
  const all = tabs.map(tab => ({ type: 'tab', tab }));
@@ -66,6 +66,7 @@ export const commandHelp = [
66
66
  { name: 'map', description: 'Open the world map' },
67
67
  { name: 'stats', description: 'Open listening stats' },
68
68
  { name: 'settings', description: 'Open settings' },
69
+ { name: 'update', description: 'Show the install command for the latest release' },
69
70
  { name: 'stop', description: 'Stop playback' },
70
71
  { name: 'help', description: 'Open this help' }
71
72
  ];
package/dist/ui/layout.js CHANGED
@@ -7,6 +7,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
7
7
  const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
8
8
  const mapMode = safeColumns >= 88 && contentRows >= 24 ? 'full' : 'compact';
9
9
  const stationRows = clamp(contentRows - 6, 1, 48);
10
+ const countryRows = clamp(contentRows - 5, 1, 64);
10
11
  return {
11
12
  columns: safeColumns,
12
13
  rows: safeRows,
@@ -14,7 +15,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
14
15
  topRows,
15
16
  contentRows,
16
17
  stationRows: compact ? 0 : stationRows,
17
- countryRows: compact ? 0 : Math.max(1, contentRows - 4),
18
+ countryRows: compact ? 0 : countryRows,
18
19
  mapCountryRows: compact ? 0 : Math.max(1, contentRows - (mapMode === 'full' ? 25 : 14)),
19
20
  mapMode,
20
21
  receiverWidth: compact ? safeColumns : Math.max(62, safeColumns - 4),
@@ -7,7 +7,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
7
7
  return `COMMAND :${commandText}`;
8
8
  }
9
9
  if (screen === 'home') {
10
- return '↑/↓ move · Enter open · number jump · : command';
10
+ return '↑/↓ move · Enter open · number jump · l location · : command';
11
11
  }
12
12
  if (screen === 'search' && editingSearch) {
13
13
  return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
@@ -27,9 +27,10 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
27
27
  if (screen === 'explore') {
28
28
  return 'Click map · WASD fine move · Shift+WASD jump · ↑/↓ station · Enter tune · f favorite · b home';
29
29
  }
30
- if (screen === 'nearby' ||
31
- screen === 'stations' ||
32
- screen === 'library') {
30
+ if (screen === 'nearby') {
31
+ return '↑/↓ or n/p move · Enter tune · f favorite · l location · [/] page · b home';
32
+ }
33
+ if (screen === 'stations' || screen === 'library') {
33
34
  return '↑/↓ or n/p move · Enter tune · f favorite · [/] page · b home';
34
35
  }
35
36
  if (screen === 'now-playing') {
@@ -16,7 +16,9 @@ export function shouldShowPlaybackFooter(station, playback) {
16
16
  return Boolean((station || playback.stationName) && visiblePlaybackStates.has(playback.state));
17
17
  }
18
18
  export function playbackFooterText({ station, playback, metadata, sleepLabel, width, spinnerFrame }) {
19
- const stationName = station?.name ?? playback.stationName;
19
+ const stationName = playback.state === 'loading'
20
+ ? playback.stationName ?? station?.name
21
+ : station?.name ?? playback.stationName;
20
22
  if (!stationName || !visiblePlaybackStates.has(playback.state)) {
21
23
  return null;
22
24
  }