@ciphore/radiocli 0.1.9 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-08
11
+
12
+ ### Added
13
+
14
+ - Added update checks for npm releases, including a `radiocli update` command,
15
+ a `:update` command, and a Settings entry that reports the latest available
16
+ version and copies or runs the right npm or Homebrew update command.
17
+ - Added lazy loading for long country station lists and search results, so
18
+ deeper directories continue loading as you scroll instead of stopping at the
19
+ first page.
20
+ - Added mouse wheel scrolling across station, country, map, nearby, explore,
21
+ search, and library lists.
22
+
23
+ ### Changed
24
+
25
+ - The footer now shows the installed RadioCLI version, and Settings includes
26
+ the current version beside update status.
27
+ - Retuning now keeps the playback footer focused on the station currently
28
+ buffering, and mpv playback stays in loading until audio has actually started.
29
+
10
30
  ## [0.1.9] - 2026-07-03
11
31
 
12
32
  ### Changed
@@ -200,6 +220,7 @@ Initial public release.
200
220
  [0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
201
221
  [0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
202
222
  [0.1.9]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.9
223
+ [0.2.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.0
203
224
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
204
225
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
205
226
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { JsonLibraryStore } from './storage/store.js';
9
9
  import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
10
10
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
11
11
  import { appVersion } from './version.js';
12
+ import { checkForUpdate, updateCommandForInstall } from './update-check.js';
12
13
  if (isDirectRun(process.argv[1], import.meta.url)) {
13
14
  const args = process.argv.slice(2);
14
15
  if (args.length > 0) {
@@ -47,6 +48,20 @@ export async function runCommand(args) {
47
48
  printPlaybackBackendStatus(backends);
48
49
  return;
49
50
  }
51
+ if (command === 'update') {
52
+ const updateCheck = await checkForUpdate();
53
+ const updateCommand = updateCommandForInstall();
54
+ if (updateCheck.error) {
55
+ console.log(`update_check=failed ${updateCheck.error}`);
56
+ }
57
+ else {
58
+ console.log(`installed=${appVersion()}`);
59
+ console.log(`latest=${updateCheck.latestVersion ?? 'unknown'}`);
60
+ console.log(`available=${updateCheck.updateAvailable ? 'yes' : 'no'}`);
61
+ }
62
+ console.log(`command=${updateCommand.command}`);
63
+ return;
64
+ }
50
65
  if (command === 'check') {
51
66
  const store = new JsonLibraryStore();
52
67
  const providers = new ProviderManager();
@@ -121,6 +136,7 @@ Usage:
121
136
  radiocli version Print the installed version
122
137
  radiocli check Show provider/backend health
123
138
  radiocli doctor Show local playback setup guidance
139
+ radiocli update Show update availability and install command
124
140
  radiocli countries Print top countries
125
141
  radiocli search <query> Search public stations
126
142
  radiocli import <file> Import .m3u, .pls, or .xspf streams
@@ -140,7 +156,7 @@ export function isDirectRun(entryPath, moduleUrl) {
140
156
  }
141
157
  }
142
158
  function isKnownCommand(command) {
143
- return ['check', 'doctor', 'countries', 'search', 'import', 'export', 'add-url'].includes(command);
159
+ return ['check', 'doctor', 'update', 'countries', 'search', 'import', 'export', 'add-url'].includes(command);
144
160
  }
145
161
  function printPlaybackBackendStatus(backends) {
146
162
  for (const line of playbackBackendStatusLines(backends)) {
@@ -10,6 +10,7 @@ import { discoverAirPlayDevices } from './airplay-discovery.js';
10
10
  import { airPlaySenderHealth } from './airplay-sender-health.js';
11
11
  import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
12
12
  const minAirPlayTuneTimeoutSeconds = 30;
13
+ const mpvAudioStartFallbackMs = 1500;
13
14
  export class PlaybackOutputError extends Error {
14
15
  constructor(message) {
15
16
  super(message);
@@ -726,6 +727,7 @@ export class PlayerController {
726
727
  async waitForReady(backend) {
727
728
  const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
728
729
  const started = Date.now();
730
+ let mpvPathReadyAt = 0;
729
731
  while (Date.now() - started < timeoutMs) {
730
732
  if (!this.process) {
731
733
  throw new Error('Player exited before the stream became ready.');
@@ -737,7 +739,13 @@ export class PlayerController {
737
739
  if (this.ipcPath) {
738
740
  try {
739
741
  await this.queryMpv({ command: ['get_property', 'path'] });
740
- return;
742
+ mpvPathReadyAt = mpvPathReadyAt || Date.now();
743
+ if (await this.hasMpvAudioStarted()) {
744
+ return;
745
+ }
746
+ if (Date.now() - mpvPathReadyAt >= mpvAudioStartFallbackMs) {
747
+ return;
748
+ }
741
749
  }
742
750
  catch {
743
751
  // The IPC socket can exist briefly before accepting commands.
@@ -748,6 +756,13 @@ export class PlayerController {
748
756
  await this.stop();
749
757
  throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
750
758
  }
759
+ async hasMpvAudioStarted() {
760
+ const [timePos, audioPts] = await Promise.all([
761
+ this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null),
762
+ this.queryMpv({ command: ['get_property', 'audio-pts'] }).catch(() => null)
763
+ ]);
764
+ return typeof timePos === 'number' || typeof audioPts === 'number';
765
+ }
751
766
  startMpvMetadataPolling() {
752
767
  this.stopMpvPolling();
753
768
  this.metadataTimer = setInterval(() => {
@@ -13,8 +13,8 @@ export class ProviderManager {
13
13
  popular(limit) {
14
14
  return this.radioBrowser.popular(limit);
15
15
  }
16
- byCountry(countryCode, limit) {
17
- return this.radioBrowser.byCountry(countryCode, limit);
16
+ byCountry(countryCode, limit, offset) {
17
+ return this.radioBrowser.byCountry(countryCode, limit, offset);
18
18
  }
19
19
  nearby(location, limit) {
20
20
  return this.radioBrowser.nearby(location, limit);
@@ -24,7 +24,7 @@ export class ProviderManager {
24
24
  }
25
25
  async search(query, settings, options = {}) {
26
26
  const radioBrowser = await this.radioBrowser.search(query, options);
27
- if (!settings.enableRadioGarden && !options.includeExperimental) {
27
+ if ((!settings.enableRadioGarden && !options.includeExperimental) || (options.offset ?? 0) > 0) {
28
28
  return radioBrowser;
29
29
  }
30
30
  const experimental = await Promise.allSettled([this.radioGarden.search(query, options)]);
@@ -87,11 +87,12 @@ export class RadioBrowserProvider {
87
87
  }, { maxAgeMs: 15 * 60 * 1000 });
88
88
  return this.normalizeStations(rows);
89
89
  }
90
- async byCountry(countryCode, limit = 100) {
90
+ async byCountry(countryCode, limit = 100, offset = 0) {
91
91
  const rows = await this.request('/json/stations/search', {
92
92
  countrycode: countryCode.toUpperCase(),
93
93
  hidebroken: 'true',
94
94
  limit: String(limit),
95
+ offset: String(Math.max(0, offset)),
95
96
  order: 'clickcount',
96
97
  reverse: 'true'
97
98
  }, { maxAgeMs: 30 * 60 * 1000 });
@@ -106,6 +107,7 @@ export class RadioBrowserProvider {
106
107
  const baseParams = {
107
108
  hidebroken: 'true',
108
109
  limit: String(limit),
110
+ offset: String(Math.max(0, options.offset ?? 0)),
109
111
  order: 'clickcount',
110
112
  reverse: 'true',
111
113
  ...(options.codec ? { codec: options.codec } : {}),
@@ -172,6 +172,15 @@ const librarySchema = z.object({
172
172
  }))
173
173
  .default([]),
174
174
  searchHistory: z.array(z.string()).default([]),
175
+ updateCheck: z
176
+ .object({
177
+ checkedAt: z.string(),
178
+ currentVersion: z.string(),
179
+ latestVersion: z.string().optional(),
180
+ updateAvailable: z.boolean(),
181
+ error: z.string().optional()
182
+ })
183
+ .optional(),
175
184
  activity: z
176
185
  .object({
177
186
  sessions: z
@@ -220,6 +229,11 @@ export class JsonLibraryStore {
220
229
  this.write();
221
230
  return this.snapshot();
222
231
  }
232
+ updateCheckState(updateCheck) {
233
+ this.state = { ...this.state, updateCheck };
234
+ this.write();
235
+ return this.snapshot();
236
+ }
223
237
  addRecent(station) {
224
238
  const key = stationKey(station);
225
239
  const recent = [
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;
@@ -74,9 +81,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
74
81
  const [sleepUntil, setSleepUntil] = useState(null);
75
82
  const [showDiagnostics, setShowDiagnostics] = useState(false);
76
83
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
84
+ const announcedUpdateRef = useRef(false);
85
+ const installingUpdateRef = useRef(false);
77
86
  const displayStationsRef = useRef([]);
78
87
  const playbackQueueRef = useRef(null);
79
88
  const lastRawTransportAtRef = useRef(0);
89
+ const loadingStationsRef = useRef(false);
90
+ const countryPageRequestRef = useRef(null);
91
+ const searchPageRequestRef = useRef(null);
80
92
  const playStationRef = useRef(() => undefined);
81
93
  const screenRef = useRef(screen);
82
94
  const selectedRef = useRef(selected);
@@ -114,6 +126,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
114
126
  }), [library, libraryStations, stationContexts]);
115
127
  screenRef.current = screen;
116
128
  selectedRef.current = selected;
129
+ loadingStationsRef.current = loadingStations;
117
130
  stationContextsRef.current = activeStationContexts;
118
131
  exploreCursorRef.current = exploreCursor;
119
132
  const renderedStationContextKey = stationContextKeyForScreen(screen);
@@ -179,14 +192,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
179
192
  }
180
193
  }), [player, store]);
181
194
  useEffect(() => {
182
- if (screen !== 'explore' || layout.compact || !stdout.isTTY) {
195
+ if (!stdout.isTTY) {
183
196
  return;
184
197
  }
185
198
  stdout.write(enableMouseReporting);
186
199
  return () => {
187
200
  stdout.write(disableMouseReporting);
188
201
  };
189
- }, [layout.compact, screen, stdout]);
202
+ }, [stdout]);
190
203
  useEffect(() => {
191
204
  selectedByScreenRef.current[screen] = selected;
192
205
  if (renderedStationContextKey) {
@@ -283,6 +296,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
283
296
  useEffect(() => {
284
297
  refreshProviderHealth();
285
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]);
286
322
  const setStationContextFor = useCallback((key, context) => {
287
323
  setStationContexts(current => ({ ...current, [key]: context }));
288
324
  }, []);
@@ -433,12 +469,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
433
469
  const loadCountry = useCallback(async (country) => {
434
470
  setLoadingStations(true);
435
471
  setMessage(null);
472
+ countryPageRequestRef.current = null;
436
473
  try {
437
- const stations = await providers.byCountry(country.code, 120);
474
+ const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
438
475
  showStationContext({
439
476
  title: country.name,
440
- subtitle: `${country.code} · ${country.stationCount.toLocaleString()} listed stations`,
441
- 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
442
481
  }, 'stations');
443
482
  }
444
483
  catch (error) {
@@ -448,6 +487,61 @@ export function App({ store: providedStore, providers: providedProviders }) {
448
487
  setLoadingStations(false);
449
488
  }
450
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
+ ]);
451
545
  const runSearch = useCallback(async (query = searchQuery) => {
452
546
  if (!query.trim()) {
453
547
  setMessage('Enter a station, genre, language, or place.');
@@ -457,15 +551,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
457
551
  setMessage(null);
458
552
  try {
459
553
  const stations = await providers.search(query, settingsRef.current, {
460
- limit: 90,
554
+ limit: SEARCH_RESULTS_PAGE_SIZE,
555
+ offset: 0,
461
556
  codec: filters.codec ?? undefined,
462
557
  language: filters.language ?? undefined,
463
558
  minBitrate: filters.minBitrate ?? undefined
464
559
  });
465
560
  setStationContextFor('search', {
466
561
  title: `Search: ${query}`,
467
- subtitle: 'Matches across enabled public station directories',
468
- 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
469
566
  });
470
567
  selectedByScreenRef.current.search = 0;
471
568
  lastSubmittedSearchRef.current = query.trim();
@@ -481,6 +578,65 @@ export function App({ store: providedStore, providers: providedProviders }) {
481
578
  setLoadingStations(false);
482
579
  }
483
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
+ ]);
484
640
  const recallSearchHistory = useCallback((direction) => {
485
641
  const history = store.snapshot().searchHistory;
486
642
  if (history.length === 0) {
@@ -580,7 +736,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
580
736
  }, []);
581
737
  const playStation = useCallback(async (station, options = {}) => {
582
738
  const queue = options.queue ?? queueFromCurrentList(station);
583
- setMessage(`Tuning ${station.name}...`);
584
739
  setNowPlaying(null);
585
740
  try {
586
741
  const resolved = await providers.resolve(station);
@@ -901,6 +1056,55 @@ export function App({ store: providedStore, providers: providedProviders }) {
901
1056
  openScreen(next.screen);
902
1057
  }
903
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]);
904
1108
  const executeCommand = useCommandExecutor({
905
1109
  beginLearningTransportKey,
906
1110
  countries,
@@ -926,6 +1130,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
926
1130
  store,
927
1131
  toggleFavorite,
928
1132
  toggleMute,
1133
+ updateCommand: handleUpdateCommand,
929
1134
  updateSettings
930
1135
  });
931
1136
  const playAdjacent = useCallback((direction) => {
@@ -1018,7 +1223,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
1018
1223
  toggleSetting,
1019
1224
  toggleNearbyLocation,
1020
1225
  toggleRadioGarden,
1021
- toggleSkipBrokenStreams
1226
+ toggleSkipBrokenStreams,
1227
+ updateFromSettings
1022
1228
  });
1023
1229
  function currentItemCount(currentScreen) {
1024
1230
  return itemCountsRef.current[currentScreen] ?? 0;
@@ -1049,7 +1255,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1049
1255
  playbackBackend: playback.backend,
1050
1256
  screen
1051
1257
  });
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) })] })] }) }));
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] })] })] })] }) }));
1053
1259
  }
1054
1260
  function buildLibraryStations(library) {
1055
1261
  const stations = [];
@@ -1080,6 +1286,31 @@ function exploreCursorLocation(cursor) {
1080
1286
  source: 'explore cursor'
1081
1287
  };
1082
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
+ }
1083
1314
  function formatExploreSubtitle(cursor, stations) {
1084
1315
  if (stations.length === 0) {
1085
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 }));
@@ -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
  }
@@ -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
  ];
@@ -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
  }
@@ -23,6 +23,7 @@ export const settingsItems = [
23
23
  'Transparent background',
24
24
  'ASCII-safe display',
25
25
  'Reduce motion',
26
+ 'Check for updates',
26
27
  'Refresh provider health',
27
28
  'Learn previous media key',
28
29
  'Learn play/pause media key',
@@ -8,16 +8,35 @@ import { textMuted, themeAccent } from '../theme.js';
8
8
  import { playbackBackendCapabilities } from '../../player/backend-install.js';
9
9
  import { airPlayReceiverSettingValue } from '../airplay-settings.js';
10
10
  import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
11
- export function SettingsScreen({ selected, settings, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width }) {
11
+ import { updateStatusText } from '../../update-check.js';
12
+ export function SettingsScreen({ selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
12
13
  const accent = themeAccent(theme);
13
14
  const lineWidth = Math.max(32, width - 4);
14
15
  const health = Object.entries(providerHealth);
15
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: settingsItems, selected: selected, keyFor: item => item, render: (item, _index, active) => {
16
- const value = settingValue(item, settings, diagnostics, backends, airPlayDevices);
17
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: item }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: textMuted, children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
18
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Status" }), _jsxs(Text, { color: textMuted, children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: textMuted, children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: textMuted, children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: textMuted, children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
16
+ const menuWindow = settingsMenuWindow(selected, height);
17
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: selected - menuWindow.start, keyFor: ({ item }) => item, render: (item, _index, active) => {
18
+ const label = settingLabel(item.item, updateCheck);
19
+ const value = settingValue(item.item, settings, diagnostics, backends, airPlayDevices, updateCheck);
20
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: label }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: textMuted, children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
21
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Status" }), _jsxs(Text, { color: textMuted, children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: textMuted, children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: textMuted, children: ["Version: ", _jsxs(Text, { color: accent, children: ["v", appVersion] }), " \u00B7 Update: ", updateStatusText(updateCheck)] }), _jsxs(Text, { color: textMuted, children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: textMuted, children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
19
22
  }
20
- function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
23
+ function settingsMenuWindow(selected, height) {
24
+ if (!height) {
25
+ return { start: 0, items: settingsItems.map(item => ({ item })) };
26
+ }
27
+ const reservedRows = 10;
28
+ const maxRows = Math.max(5, height - reservedRows);
29
+ if (settingsItems.length <= maxRows) {
30
+ return { start: 0, items: settingsItems.map(item => ({ item })) };
31
+ }
32
+ const clampedSelected = Math.min(Math.max(selected, 0), settingsItems.length - 1);
33
+ const start = Math.min(Math.floor(clampedSelected / maxRows) * maxRows, Math.max(0, settingsItems.length - maxRows));
34
+ return {
35
+ start,
36
+ items: settingsItems.slice(start, start + maxRows).map(item => ({ item }))
37
+ };
38
+ }
39
+ function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
21
40
  switch (item) {
22
41
  case 'Cycle display color':
23
42
  return settings.theme;
@@ -46,9 +65,17 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
46
65
  return settings.asciiMode ? 'on' : 'off';
47
66
  case 'Reduce motion':
48
67
  return settings.reduceMotion ? 'on' : 'off';
68
+ case 'Check for updates':
69
+ return updateStatusText(updateCheck);
49
70
  case 'Reset learned media keys':
50
71
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
51
72
  default:
52
73
  return undefined;
53
74
  }
54
75
  }
76
+ function settingLabel(item, updateCheck) {
77
+ if (item === 'Check for updates' && updateCheck?.updateAvailable) {
78
+ return 'Install update';
79
+ }
80
+ return item;
81
+ }
@@ -61,13 +61,34 @@ const settings = {
61
61
  describe('SettingsScreen rendering', () => {
62
62
  it('renders the new display and playback toggles with their values', () => {
63
63
  const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
64
- const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
64
+ const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
65
65
  const frame = lastFrame() ?? '';
66
66
  expect(frame).toContain('Resume last station on launch');
67
67
  expect(frame).toContain('ASCII-safe display');
68
68
  expect(frame).toContain('Reduce motion');
69
69
  expect(frame).toContain('Transparent background');
70
70
  });
71
+ it('changes the update settings row when an update is available', () => {
72
+ const settingsIndex = Math.max(0, settingsItems.indexOf('Check for updates'));
73
+ const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.10', updateAvailable: true }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
74
+ const frame = lastFrame() ?? '';
75
+ expect(frame).toContain('Install update');
76
+ expect(frame).toContain('v0.1.10 available');
77
+ });
78
+ it('keeps the selected update row visible in a constrained Settings pane', () => {
79
+ const settingsIndex = Math.max(0, settingsItems.indexOf('Check for updates'));
80
+ const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80, height: 18 }));
81
+ const frame = lastFrame() ?? '';
82
+ expect(frame).toContain('> Check for updates');
83
+ });
84
+ it('keeps Reduce motion visible between ASCII-safe display and update checks', () => {
85
+ const settingsIndex = Math.max(0, settingsItems.indexOf('Reduce motion'));
86
+ const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80, height: 18 }));
87
+ const frame = lastFrame() ?? '';
88
+ expect(frame).toContain(' ASCII-safe display');
89
+ expect(frame).toContain('> Reduce motion');
90
+ expect(frame).toContain(' Check for updates');
91
+ });
71
92
  });
72
93
  function renderExplore(asciiMode) {
73
94
  const mode = resolveDisplayMode({ asciiMode }, {});
@@ -14,6 +14,21 @@ export function parseSgrMouseEvents(input) {
14
14
  export function primaryMousePress(events) {
15
15
  return events.find(event => event.pressed && (event.button & 3) === 0 && (event.button & 96) === 0) ?? null;
16
16
  }
17
+ export function wheelScrollDelta(events) {
18
+ return events.reduce((delta, event) => {
19
+ if (!event.pressed) {
20
+ return delta;
21
+ }
22
+ const button = event.button & 127;
23
+ if (button === 64) {
24
+ return delta - 1;
25
+ }
26
+ if (button === 65) {
27
+ return delta + 1;
28
+ }
29
+ return delta;
30
+ }, 0);
31
+ }
17
32
  export function exploreCursorForMouseCell(x, y, frameWidth, layout) {
18
33
  if (layout.compact) {
19
34
  return null;
@@ -2,9 +2,9 @@ import { useEffect } from 'react';
2
2
  import { useInput } from 'ink';
3
3
  import { homeItems, settingsItems } from './screen-items.js';
4
4
  import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldToggleNearbyLocationShortcut } from './app-state.js';
5
- import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
5
+ import { parseSgrMouseEvents, primaryMousePress, wheelScrollDelta } from './terminal-mouse.js';
6
6
  import { completeCommand } from './help-content.js';
7
- export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
7
+ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams, updateFromSettings }) {
8
8
  useEffect(() => {
9
9
  const onData = (data) => {
10
10
  const rawInput = String(data);
@@ -22,8 +22,13 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
22
22
  }
23
23
  const mouseEvents = parseSgrMouseEvents(rawInput);
24
24
  if (mouseEvents.length > 0) {
25
+ const wheelDelta = wheelScrollDelta(mouseEvents);
25
26
  const click = primaryMousePress(mouseEvents);
26
27
  lastRawTransportAtRef.current = Date.now();
28
+ if (wheelDelta !== 0 && shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter)) {
29
+ setSelected(value => clamp(value + wheelDelta * 3, currentItemCount(screen) - 1));
30
+ return;
31
+ }
27
32
  if (!commandMode && screen === 'explore' && click) {
28
33
  moveExploreCursorToCell(click.x, click.y);
29
34
  }
@@ -426,6 +431,9 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
426
431
  else if (item === 'Reduce motion') {
427
432
  toggleSetting('reduceMotion');
428
433
  }
434
+ else if (item === 'Check for updates') {
435
+ void updateFromSettings();
436
+ }
429
437
  else if (item === 'Refresh provider health') {
430
438
  refreshProviderHealth();
431
439
  setMessage('Provider health refreshed.');
@@ -461,6 +469,12 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
461
469
  }
462
470
  });
463
471
  }
472
+ function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
473
+ if (commandMode || editingCountryFilter) {
474
+ return false;
475
+ }
476
+ return ['countries', 'map', 'stations', 'search', 'nearby', 'explore', 'library'].includes(screen);
477
+ }
464
478
  function exploreMoveForInput(input) {
465
479
  const normalized = input.toLowerCase();
466
480
  if (normalized === 'w') {
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
3
- export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
3
+ export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
4
4
  return useCallback(async (rawCommand) => {
5
5
  const trimmed = rawCommand.trim();
6
6
  if (!trimmed) {
@@ -136,6 +136,10 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
136
136
  go('settings');
137
137
  return;
138
138
  }
139
+ if (name === 'update') {
140
+ await updateCommand();
141
+ return;
142
+ }
139
143
  if (name === 'stop') {
140
144
  setLibrary(store.finishActiveListeningSession());
141
145
  await player.stop();
@@ -189,6 +193,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
189
193
  store,
190
194
  toggleFavorite,
191
195
  toggleMute,
196
+ updateCommand,
192
197
  updateSettings
193
198
  ]);
194
199
  }
@@ -0,0 +1,122 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { appVersion } from './version.js';
4
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
5
+ const UPDATE_PACKAGE_NAME = '@ciphore/radiocli';
6
+ export async function checkForUpdate({ currentVersion = appVersion(), fetchImpl = fetch, now = new Date(), packageName = UPDATE_PACKAGE_NAME, timeoutMs = 3000 } = {}) {
7
+ try {
8
+ const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs);
9
+ if (!response.ok) {
10
+ throw new Error(`npm registry returned ${response.status}`);
11
+ }
12
+ const parsed = await response.json();
13
+ const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined;
14
+ if (!latestVersion) {
15
+ throw new Error('npm registry response did not include a version');
16
+ }
17
+ return {
18
+ checkedAt: now.toISOString(),
19
+ currentVersion,
20
+ latestVersion,
21
+ updateAvailable: compareSemver(latestVersion, currentVersion) > 0
22
+ };
23
+ }
24
+ catch (error) {
25
+ return {
26
+ checkedAt: now.toISOString(),
27
+ currentVersion,
28
+ updateAvailable: false,
29
+ error: error instanceof Error ? error.message : 'Update check failed'
30
+ };
31
+ }
32
+ }
33
+ export function shouldCheckForUpdate(updateCheck, now = Date.now()) {
34
+ if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') {
35
+ return false;
36
+ }
37
+ if (!updateCheck?.checkedAt) {
38
+ return true;
39
+ }
40
+ const checkedAt = Date.parse(updateCheck.checkedAt);
41
+ return !Number.isFinite(checkedAt) || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
42
+ }
43
+ export function updateStatusText(updateCheck) {
44
+ if (!updateCheck) {
45
+ return 'not checked yet';
46
+ }
47
+ if (updateCheck.updateAvailable && updateCheck.latestVersion) {
48
+ return `v${updateCheck.latestVersion} available`;
49
+ }
50
+ if (updateCheck.error) {
51
+ return `check failed: ${updateCheck.error}`;
52
+ }
53
+ return updateCheck.latestVersion ? `current at v${updateCheck.latestVersion}` : 'not checked yet';
54
+ }
55
+ export function updateCommandForInstall(entryPath = process.argv[1]) {
56
+ const resolved = resolvePath(entryPath);
57
+ const haystack = [entryPath, resolved].filter(Boolean).join('\n');
58
+ if (/\/(?:opt\/homebrew|usr\/local)\/(?:Cellar|Homebrew)\//.test(haystack) || /\/\.linuxbrew\/(?:Cellar|Homebrew)\//.test(haystack)) {
59
+ return { method: 'homebrew', command: 'brew update && brew upgrade radiocli' };
60
+ }
61
+ if (/\/node_modules\/@ciphore\/radiocli\//.test(haystack)) {
62
+ return { method: 'npm', command: 'npm install -g @ciphore/radiocli@latest' };
63
+ }
64
+ return { method: 'unknown', command: 'npm install -g @ciphore/radiocli@latest' };
65
+ }
66
+ export function installUpdate(command = updateCommandForInstall().command) {
67
+ return new Promise(resolve => {
68
+ const shell = process.platform === 'win32' ? 'cmd.exe' : 'sh';
69
+ const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
70
+ const child = spawn(shell, args, { stdio: ['ignore', 'pipe', 'pipe'] });
71
+ const chunks = [];
72
+ child.stdout.on('data', chunk => chunks.push(Buffer.from(chunk)));
73
+ child.stderr.on('data', chunk => chunks.push(Buffer.from(chunk)));
74
+ child.on('error', error => {
75
+ resolve({ ok: false, command, output: error.message });
76
+ });
77
+ child.on('close', code => {
78
+ const output = Buffer.concat(chunks).toString('utf8').trim();
79
+ resolve({ ok: code === 0, command, output });
80
+ });
81
+ });
82
+ }
83
+ export function compareSemver(left, right) {
84
+ const leftParts = semverParts(left);
85
+ const rightParts = semverParts(right);
86
+ for (let index = 0; index < 3; index += 1) {
87
+ const delta = leftParts[index] - rightParts[index];
88
+ if (delta !== 0) {
89
+ return delta;
90
+ }
91
+ }
92
+ return 0;
93
+ }
94
+ function registryLatestUrl(packageName) {
95
+ return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
96
+ }
97
+ async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
100
+ try {
101
+ return await fetchImpl(url, { signal: controller.signal });
102
+ }
103
+ finally {
104
+ clearTimeout(timer);
105
+ }
106
+ }
107
+ function semverParts(version) {
108
+ const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? '';
109
+ const [major = '0', minor = '0', patch = '0'] = normalized.split('.');
110
+ return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
111
+ }
112
+ function resolvePath(path) {
113
+ if (!path) {
114
+ return undefined;
115
+ }
116
+ try {
117
+ return realpathSync(path);
118
+ }
119
+ catch {
120
+ return path;
121
+ }
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",