@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/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ 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
+
30
+ ## [0.1.9] - 2026-07-03
31
+
32
+ ### Changed
33
+
34
+ - Nearby station discovery is now enabled by default for new libraries, and the
35
+ `l` shortcut is limited to Overview, Nearby, and Settings so country lists
36
+ keep normal letter navigation.
37
+ - Favorite changes from Library now appear in the footer message row instead of
38
+ displacing the main page status.
39
+ - The listening stats activity graph now uses solid truecolor cell backgrounds
40
+ with tighter rendering on short terminals and fewer split color runs.
41
+
42
+ ### Fixed
43
+
44
+ - Nearby keeps showing the last loaded station list when location lookup is
45
+ turned off or temporarily unavailable.
46
+ - Long country names are truncated to one terminal row so dense country lists do
47
+ not wrap into nearby entries.
48
+ - Pressing `b` from a country station list now returns to Countries before
49
+ falling back to Overview.
50
+
10
51
  ## [0.1.8] - 2026-07-02
11
52
 
12
53
  ### Changed
@@ -178,6 +219,8 @@ Initial public release.
178
219
  [0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
179
220
  [0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
180
221
  [0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
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
181
224
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
182
225
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
183
226
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/README.md CHANGED
@@ -76,7 +76,7 @@ Live public radio from around the world
76
76
 
77
77
  3 recent · 2 favorites · 1 imported
78
78
 
79
- ↑/↓ move · Enter open · number jump · : command
79
+ ↑/↓ move · Enter open · number jump · l location · : command
80
80
  ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
81
81
  ```
82
82
 
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 } : {}),
@@ -28,8 +28,7 @@ const stationSchema = z
28
28
  distanceKm: z.number().optional(),
29
29
  hls: z.boolean().optional(),
30
30
  lastCheckedOk: z.boolean().optional()
31
- })
32
- .strict();
31
+ });
33
32
  const defaultMediaKeys = {
34
33
  previous: [],
35
34
  playPause: [],
@@ -138,7 +137,7 @@ const settingsSchema = z.object({
138
137
  receiverStyleVersion: z.number().optional(),
139
138
  volume: z.number().min(0).max(100).default(70),
140
139
  enableRadioGarden: z.boolean().default(false),
141
- enableNearbyLocation: z.boolean().default(false),
140
+ enableNearbyLocation: z.boolean().default(true),
142
141
  preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
143
142
  preferredAirPlayDevice: z.string().min(1).optional(),
144
143
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
@@ -173,6 +172,15 @@ const librarySchema = z.object({
173
172
  }))
174
173
  .default([]),
175
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(),
176
184
  activity: z
177
185
  .object({
178
186
  sessions: z
@@ -192,7 +200,7 @@ const librarySchema = z.object({
192
200
  receiverStyleVersion: 2,
193
201
  volume: 70,
194
202
  enableRadioGarden: false,
195
- enableNearbyLocation: false,
203
+ enableNearbyLocation: true,
196
204
  preferredBackend: 'auto',
197
205
  tuneTimeoutSeconds: 12,
198
206
  skipBrokenStreams: true,
@@ -221,6 +229,11 @@ export class JsonLibraryStore {
221
229
  this.write();
222
230
  return this.snapshot();
223
231
  }
232
+ updateCheckState(updateCheck) {
233
+ this.state = { ...this.state, updateCheck };
234
+ this.write();
235
+ return this.snapshot();
236
+ }
224
237
  addRecent(station) {
225
238
  const key = stationKey(station);
226
239
  const recent = [
@@ -387,7 +400,7 @@ function defaultState() {
387
400
  receiverStyleVersion: 2,
388
401
  volume: 70,
389
402
  enableRadioGarden: false,
390
- enableNearbyLocation: false,
403
+ enableNearbyLocation: true,
391
404
  preferredBackend: 'auto',
392
405
  tuneTimeoutSeconds: 12,
393
406
  skipBrokenStreams: true,