@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 +43 -0
- package/README.md +1 -1
- package/dist/cli.js +17 -1
- package/dist/player/player-controller.js +16 -1
- package/dist/providers/provider-manager.js +3 -3
- package/dist/providers/radio-browser.js +3 -1
- package/dist/storage/store.js +18 -5
- package/dist/ui/App.js +278 -14
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/components/Menu.js +2 -1
- package/dist/ui/components/StationList.js +2 -2
- package/dist/ui/components/TopTabs.js +2 -2
- package/dist/ui/help-content.js +1 -0
- package/dist/ui/layout.js +2 -1
- package/dist/ui/page-footer.js +5 -4
- package/dist/ui/playback-footer.js +3 -1
- package/dist/ui/screen-items.js +1 -0
- package/dist/ui/screens/CountriesScreen.js +7 -1
- package/dist/ui/screens/SettingsScreen.js +33 -6
- package/dist/ui/screens/StatsScreen.js +34 -16
- package/dist/ui/screens/screen-render.test.js +54 -2
- package/dist/ui/terminal-mouse.js +15 -0
- package/dist/ui/use-app-input.js +22 -4
- package/dist/ui/use-command-executor.js +6 -1
- package/dist/update-check.js +122 -0
- package/package.json +1 -1
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
|
-
|
|
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 } : {}),
|
package/dist/storage/store.js
CHANGED
|
@@ -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(
|
|
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:
|
|
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:
|
|
403
|
+
enableNearbyLocation: true,
|
|
391
404
|
preferredBackend: 'auto',
|
|
392
405
|
tuneTimeoutSeconds: 12,
|
|
393
406
|
skipBrokenStreams: true,
|