@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 +21 -0
- 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 +14 -0
- package/dist/ui/App.js +242 -11
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/components/StationList.js +2 -2
- package/dist/ui/help-content.js +1 -0
- package/dist/ui/playback-footer.js +3 -1
- package/dist/ui/screen-items.js +1 -0
- package/dist/ui/screens/SettingsScreen.js +33 -6
- package/dist/ui/screens/screen-render.test.js +22 -1
- package/dist/ui/terminal-mouse.js +15 -0
- package/dist/ui/use-app-input.js +16 -2
- 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,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
|
-
|
|
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
|
@@ -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 (
|
|
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
|
-
}, [
|
|
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,
|
|
474
|
+
const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
|
|
438
475
|
showStationContext({
|
|
439
476
|
title: country.name,
|
|
440
|
-
subtitle:
|
|
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:
|
|
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:
|
|
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)}`;
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
}
|
package/dist/ui/help-content.js
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/dist/ui/screen-items.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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;
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -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
|
+
}
|