@ciphore/radiocli 0.2.1 → 0.2.3
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 +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
package/dist/ui/App.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
|
|
4
5
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
5
6
|
import { PlayerController } from '../player/player-controller.js';
|
|
6
7
|
import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
7
8
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
8
|
-
import {
|
|
9
|
+
import { defaultAgentControlSettings } from '../types.js';
|
|
9
10
|
import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
|
|
10
11
|
import { DisplayContext, resolveDisplayMode } from './display-context.js';
|
|
11
|
-
import { homeItems,
|
|
12
|
+
import { homeItems, settingsItemsForPage } from './screen-items.js';
|
|
12
13
|
import { AppContent } from './AppContent.js';
|
|
13
14
|
import { TopTabs } from './components/TopTabs.js';
|
|
14
15
|
import { computeTerminalLayout } from './layout.js';
|
|
15
16
|
import { truncate } from './format.js';
|
|
16
17
|
import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
|
|
17
|
-
import { fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, pageFooterText } from './page-footer.js';
|
|
18
|
+
import { balancedFooterLegendRows, fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, microShortcutFooterText, pageFooterText } from './page-footer.js';
|
|
18
19
|
import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
|
|
19
20
|
import { useAppInput } from './use-app-input.js';
|
|
20
21
|
import { useCommandExecutor } from './use-command-executor.js';
|
|
@@ -23,14 +24,18 @@ import { isAirPlayBackendAvailable } from './airplay-settings.js';
|
|
|
23
24
|
import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
24
25
|
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
25
26
|
import { appVersion } from '../version.js';
|
|
26
|
-
import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
|
|
27
|
+
import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
|
|
27
28
|
import { helpItemCount } from './help-content.js';
|
|
28
29
|
import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
|
|
31
|
+
import { useAlarmTui } from './use-alarm-tui.js';
|
|
32
|
+
import { toAsciiSafe } from './ascii.js';
|
|
33
|
+
import { registerTuiPresence } from '../alarms/tui-presence.js';
|
|
34
|
+
import { configureMcpIntegrations } from '../agent/mcp-install.js';
|
|
35
|
+
import { startRadioSession } from '../agent/session.js';
|
|
36
|
+
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
37
|
+
import { ReceiverAnimationProvider } from './receiver-animation.js';
|
|
38
|
+
import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
|
|
34
39
|
const LOADING_SPINNER_MS = 120;
|
|
35
40
|
const VISUALIZER_MESSAGE_MS = 4500;
|
|
36
41
|
const LISTENING_HEARTBEAT_MS = 30_000;
|
|
@@ -38,20 +43,23 @@ const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
|
38
43
|
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
39
44
|
const SEARCH_RESULTS_PAGE_SIZE = 90;
|
|
40
45
|
const SEARCH_RESULTS_LOAD_AHEAD = 12;
|
|
46
|
+
const BROKEN_STREAM_AUTO_SKIP_DELAY_MS = 1000;
|
|
41
47
|
const settingToggleLabel = {
|
|
42
48
|
resumeOnLaunch: 'Resume on launch',
|
|
43
49
|
transparentBackground: 'Transparent background',
|
|
44
50
|
asciiMode: 'ASCII-safe display',
|
|
45
51
|
reduceMotion: 'Reduce motion',
|
|
46
|
-
mouseSupport: 'Mouse and trackpad scrolling'
|
|
52
|
+
mouseSupport: 'Mouse and trackpad scrolling',
|
|
53
|
+
automaticUpdateChecks: 'Automatic update checks'
|
|
47
54
|
};
|
|
48
|
-
export function App({ store: providedStore, providers: providedProviders }) {
|
|
55
|
+
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate }) {
|
|
49
56
|
const { exit } = useApp();
|
|
50
57
|
const { stdin } = useStdin();
|
|
51
58
|
const { stdout } = useStdout();
|
|
52
59
|
const { columns, rows } = useWindowSize();
|
|
53
60
|
const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
|
|
54
61
|
const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
|
|
62
|
+
const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
|
|
55
63
|
const installedVersion = useMemo(() => appVersion(), []);
|
|
56
64
|
const [library, setLibrary] = useState(() => store.snapshot());
|
|
57
65
|
const settingsRef = useRef(library.settings);
|
|
@@ -62,6 +70,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
62
70
|
const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
|
|
63
71
|
const [screen, setScreen] = useState('home');
|
|
64
72
|
const [selected, setSelected] = useState(0);
|
|
73
|
+
const [settingsPage, setSettingsPage] = useState('root');
|
|
65
74
|
const [message, setMessage] = useState(null);
|
|
66
75
|
const [footerMessage, setFooterMessage] = useState(null);
|
|
67
76
|
const [countries, setCountries] = useState([]);
|
|
@@ -78,7 +87,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
78
87
|
const [location, setLocation] = useState(null);
|
|
79
88
|
const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
|
|
80
89
|
const [providerHealth, setProviderHealth] = useState({});
|
|
81
|
-
const [pulse, setPulse] = useState(0);
|
|
82
90
|
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
83
91
|
const [commandMode, setCommandMode] = useState(false);
|
|
84
92
|
const [commandText, setCommandText] = useState('');
|
|
@@ -88,7 +96,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
88
96
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
89
97
|
const [capturingTransportAction, setCapturingTransportAction] = useState(null);
|
|
90
98
|
const announcedUpdateRef = useRef(false);
|
|
99
|
+
const automaticUpdateCheckStartedRef = useRef(false);
|
|
91
100
|
const installingUpdateRef = useRef(false);
|
|
101
|
+
const configuringMcpRef = useRef(false);
|
|
92
102
|
const displayStationsRef = useRef([]);
|
|
93
103
|
const playbackQueueRef = useRef(null);
|
|
94
104
|
const lastRawTransportAtRef = useRef(0);
|
|
@@ -98,6 +108,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
98
108
|
const playStationRef = useRef(() => undefined);
|
|
99
109
|
const screenRef = useRef(screen);
|
|
100
110
|
const selectedRef = useRef(selected);
|
|
111
|
+
const settingsPageRef = useRef(settingsPage);
|
|
112
|
+
const selectedBySettingsPageRef = useRef({});
|
|
101
113
|
const selectedByScreenRef = useRef({});
|
|
102
114
|
const stationContextsRef = useRef(stationContexts);
|
|
103
115
|
const lastStationContextKeyRef = useRef('explore');
|
|
@@ -109,7 +121,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
109
121
|
const transientMessageTimerRef = useRef(null);
|
|
110
122
|
const transientFooterMessageTimerRef = useRef(null);
|
|
111
123
|
const exitConfirmationUntilRef = useRef(0);
|
|
112
|
-
const receiverPulseSnapshotRef = useRef(null);
|
|
113
124
|
const helpReturnScreenRef = useRef('home');
|
|
114
125
|
const countriesLoadAttemptedRef = useRef(false);
|
|
115
126
|
const tuneRequestRef = useRef(0);
|
|
@@ -118,6 +129,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
118
129
|
const nearbyRequestRef = useRef(0);
|
|
119
130
|
const skipBrokenTimerRef = useRef(null);
|
|
120
131
|
const volumeRequestRef = useRef(0);
|
|
132
|
+
const agentHandlerRef = useRef(async () => {
|
|
133
|
+
throw new Error('RadioCLI is still starting.');
|
|
134
|
+
});
|
|
121
135
|
const theme = library.settings.theme;
|
|
122
136
|
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
123
137
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
@@ -141,6 +155,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
141
155
|
}), [library, libraryStations, stationContexts]);
|
|
142
156
|
screenRef.current = screen;
|
|
143
157
|
selectedRef.current = selected;
|
|
158
|
+
settingsPageRef.current = settingsPage;
|
|
144
159
|
loadingStationsRef.current = loadingStations;
|
|
145
160
|
stationContextsRef.current = activeStationContexts;
|
|
146
161
|
exploreCursorRef.current = exploreCursor;
|
|
@@ -165,9 +180,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
165
180
|
'now-playing': 1,
|
|
166
181
|
library: 0,
|
|
167
182
|
stats: 1,
|
|
183
|
+
alarms: 0,
|
|
184
|
+
'alarm-editor': 0,
|
|
185
|
+
'alarm-picker': 0,
|
|
186
|
+
'alarm-ringing': 0,
|
|
168
187
|
'airplay-settings': 0,
|
|
169
188
|
'airplay-code': 1,
|
|
170
|
-
settings:
|
|
189
|
+
settings: settingsItemsForPage(settingsPage).length,
|
|
171
190
|
help: helpItemCount
|
|
172
191
|
});
|
|
173
192
|
itemCountsRef.current = {
|
|
@@ -181,9 +200,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
181
200
|
'now-playing': 1,
|
|
182
201
|
library: stationCounts.library,
|
|
183
202
|
stats: 1,
|
|
203
|
+
alarms: library.alarms.length,
|
|
204
|
+
'alarm-editor': 0,
|
|
205
|
+
'alarm-picker': 0,
|
|
206
|
+
'alarm-ringing': 0,
|
|
184
207
|
'airplay-settings': availableAirPlayDevices.length,
|
|
185
208
|
'airplay-code': 1,
|
|
186
|
-
settings:
|
|
209
|
+
settings: settingsItemsForPage(settingsPage).length,
|
|
187
210
|
help: helpItemCount
|
|
188
211
|
};
|
|
189
212
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
@@ -204,6 +227,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
204
227
|
!editingCountryFilter &&
|
|
205
228
|
shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
|
|
206
229
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
230
|
+
useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]);
|
|
207
231
|
const playingStationRef = useRef(null);
|
|
208
232
|
playingStationRef.current = playingStation;
|
|
209
233
|
const activeListeningStationRef = useRef(null);
|
|
@@ -259,33 +283,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
259
283
|
lastStationContextKeyRef.current = renderedStationContextKey;
|
|
260
284
|
}
|
|
261
285
|
}, [renderedStationContextKey, screen, selected]);
|
|
262
|
-
useEffect(() => {
|
|
263
|
-
const current = {
|
|
264
|
-
screen,
|
|
265
|
-
receiverStyle: library.settings.receiverStyle,
|
|
266
|
-
playbackState: playback.state,
|
|
267
|
-
playbackReady: playback.ready
|
|
268
|
-
};
|
|
269
|
-
if (shouldResetReceiverPulse(receiverPulseSnapshotRef.current, current)) {
|
|
270
|
-
setPulse(0);
|
|
271
|
-
}
|
|
272
|
-
receiverPulseSnapshotRef.current = current;
|
|
273
|
-
}, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
|
|
274
|
-
useEffect(() => {
|
|
275
|
-
if (!shouldAnimateReceiver(screen, playback) ||
|
|
276
|
-
library.settings.reduceMotion ||
|
|
277
|
-
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
278
|
-
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
const intervalMs = library.settings.receiverStyle === 'skyline'
|
|
282
|
-
? SKYLINE_RECEIVER_PULSE_MS
|
|
283
|
-
: LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle)
|
|
284
|
-
? LIVE_RECEIVER_PULSE_MS
|
|
285
|
-
: AMBIENT_RECEIVER_PULSE_MS;
|
|
286
|
-
const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
|
|
287
|
-
return () => clearInterval(timer);
|
|
288
|
-
}, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
|
|
289
286
|
useEffect(() => {
|
|
290
287
|
if (footerPlayback.state !== 'loading' ||
|
|
291
288
|
library.settings.reduceMotion ||
|
|
@@ -363,14 +360,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
363
360
|
refreshProviderHealth();
|
|
364
361
|
}, [refreshProviderHealth]);
|
|
365
362
|
const refreshUpdateCheck = useCallback(async () => {
|
|
366
|
-
const updateCheck = await
|
|
363
|
+
const updateCheck = await updateChecker({ currentVersion: installedVersion });
|
|
367
364
|
setLibrary(store.updateCheckState(updateCheck));
|
|
368
365
|
return updateCheck;
|
|
369
|
-
}, [installedVersion, store]);
|
|
366
|
+
}, [installedVersion, store, updateChecker]);
|
|
370
367
|
useEffect(() => {
|
|
371
|
-
if (
|
|
368
|
+
if (automaticUpdateCheckStartedRef.current ||
|
|
369
|
+
!automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
|
|
372
370
|
return;
|
|
373
371
|
}
|
|
372
|
+
automaticUpdateCheckStartedRef.current = true;
|
|
374
373
|
let cancelled = false;
|
|
375
374
|
void refreshUpdateCheck().then(updateCheck => {
|
|
376
375
|
if (cancelled) {
|
|
@@ -384,7 +383,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
384
383
|
return () => {
|
|
385
384
|
cancelled = true;
|
|
386
385
|
};
|
|
387
|
-
}, [library.
|
|
386
|
+
}, [library.settings.automaticUpdateChecks, refreshUpdateCheck]);
|
|
388
387
|
const setStationContextFor = useCallback((key, context) => {
|
|
389
388
|
setStationContexts(current => ({ ...current, [key]: context }));
|
|
390
389
|
}, []);
|
|
@@ -432,18 +431,40 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
432
431
|
const destination = screenRef.current === 'help' && next === 'home'
|
|
433
432
|
? helpReturnScreenRef.current
|
|
434
433
|
: next;
|
|
434
|
+
if (destination === 'settings' &&
|
|
435
|
+
screenRef.current !== 'settings' &&
|
|
436
|
+
screenRef.current !== 'airplay-settings' &&
|
|
437
|
+
screenRef.current !== 'airplay-code') {
|
|
438
|
+
settingsPageRef.current = 'root';
|
|
439
|
+
setSettingsPage('root');
|
|
440
|
+
itemCountsRef.current.settings = settingsItemsForPage('root').length;
|
|
441
|
+
selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
|
|
442
|
+
}
|
|
435
443
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
436
444
|
const remembered = selectedByScreenRef.current[destination] ?? 0;
|
|
437
445
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
438
|
-
if (destination === 'now-playing' && screenRef.current !== 'now-playing') {
|
|
439
|
-
setPulse(0);
|
|
440
|
-
}
|
|
441
446
|
setScreen(destination);
|
|
442
447
|
setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
|
|
443
448
|
if (options.clearMessage !== false) {
|
|
444
449
|
setMessage(null);
|
|
445
450
|
}
|
|
446
451
|
}, []);
|
|
452
|
+
const openSettingsPage = useCallback((next) => {
|
|
453
|
+
selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
|
|
454
|
+
settingsPageRef.current = next;
|
|
455
|
+
setSettingsPage(next);
|
|
456
|
+
const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
|
|
457
|
+
selectedRef.current = nextSelection;
|
|
458
|
+
setSelected(nextSelection);
|
|
459
|
+
setMessage(null);
|
|
460
|
+
}, []);
|
|
461
|
+
const closeSettingsPage = useCallback(() => {
|
|
462
|
+
if (settingsPageRef.current === 'root') {
|
|
463
|
+
go('home');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
openSettingsPage('root');
|
|
467
|
+
}, [go, openSettingsPage]);
|
|
447
468
|
const openAirPlayCode = useCallback(() => {
|
|
448
469
|
setAirPlayCode('');
|
|
449
470
|
go('airplay-code', { resetSelection: true, clearMessage: false });
|
|
@@ -488,7 +509,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
488
509
|
setStationContextFor('explore', {
|
|
489
510
|
title: 'Explore world',
|
|
490
511
|
subtitle: `Scanning all geotagged stations near ${formatExploreCursor(cursor)}`,
|
|
491
|
-
stations: previousExploreStations
|
|
512
|
+
stations: previousExploreStations,
|
|
513
|
+
error: undefined
|
|
492
514
|
});
|
|
493
515
|
try {
|
|
494
516
|
const stations = await providers.nearby(exploreCursorLocation(cursor), 90);
|
|
@@ -512,7 +534,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
512
534
|
if (requestId !== exploreRequestRef.current) {
|
|
513
535
|
return;
|
|
514
536
|
}
|
|
515
|
-
|
|
537
|
+
const loadError = discoveryLoadError('Could not load world stations.', error);
|
|
538
|
+
setStationContextFor('explore', {
|
|
539
|
+
...stationContextsRef.current.explore,
|
|
540
|
+
subtitle: `World station directory unavailable near ${formatExploreCursor(cursor)}`,
|
|
541
|
+
error: loadError
|
|
542
|
+
});
|
|
543
|
+
setMessage(loadError);
|
|
516
544
|
}
|
|
517
545
|
finally {
|
|
518
546
|
if (requestId === exploreRequestRef.current) {
|
|
@@ -765,6 +793,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
765
793
|
nearbyRequestRef.current = requestId;
|
|
766
794
|
setLoadingStations(true);
|
|
767
795
|
setMessage(null);
|
|
796
|
+
setStationContextFor('nearby', {
|
|
797
|
+
...stationContextsRef.current.nearby,
|
|
798
|
+
error: undefined
|
|
799
|
+
});
|
|
768
800
|
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
769
801
|
try {
|
|
770
802
|
if (!settingsRef.current.enableNearbyLocation) {
|
|
@@ -775,7 +807,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
775
807
|
setStationContextFor('nearby', {
|
|
776
808
|
title: 'Nearby',
|
|
777
809
|
subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
|
|
778
|
-
stations: []
|
|
810
|
+
stations: [],
|
|
811
|
+
error: undefined
|
|
779
812
|
});
|
|
780
813
|
return;
|
|
781
814
|
}
|
|
@@ -791,7 +824,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
791
824
|
setStationContextFor('nearby', {
|
|
792
825
|
title: 'Nearby',
|
|
793
826
|
subtitle: 'Location detection was unavailable',
|
|
794
|
-
stations: []
|
|
827
|
+
stations: [],
|
|
828
|
+
error: 'Could not determine an approximate location.'
|
|
795
829
|
});
|
|
796
830
|
return;
|
|
797
831
|
}
|
|
@@ -807,7 +841,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
807
841
|
catch (error) {
|
|
808
842
|
if (requestId !== nearbyRequestRef.current)
|
|
809
843
|
return;
|
|
810
|
-
|
|
844
|
+
const loadError = discoveryLoadError('Could not load nearby stations.', error);
|
|
845
|
+
setStationContextFor('nearby', {
|
|
846
|
+
...stationContextsRef.current.nearby,
|
|
847
|
+
error: loadError
|
|
848
|
+
});
|
|
849
|
+
setMessage(loadError);
|
|
811
850
|
}
|
|
812
851
|
finally {
|
|
813
852
|
if (requestId === nearbyRequestRef.current)
|
|
@@ -841,6 +880,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
841
880
|
selectedByScreenRef.current[queue.sourceScreen] = index;
|
|
842
881
|
}
|
|
843
882
|
if (screenRef.current === queue.sourceScreen) {
|
|
883
|
+
selectedRef.current = index;
|
|
844
884
|
setSelected(index);
|
|
845
885
|
}
|
|
846
886
|
}, []);
|
|
@@ -867,6 +907,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
867
907
|
await player.play(station, resolved.url);
|
|
868
908
|
if (requestId !== tuneRequestRef.current)
|
|
869
909
|
return;
|
|
910
|
+
playingStationRef.current = station;
|
|
870
911
|
setPlayingStation(station);
|
|
871
912
|
setTuningStation(null);
|
|
872
913
|
playbackQueueRef.current = queue;
|
|
@@ -899,7 +940,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
899
940
|
skipBrokenTimerRef.current = setTimeout(() => {
|
|
900
941
|
skipBrokenTimerRef.current = null;
|
|
901
942
|
playStationRef.current(nextStation, { ...options, queue });
|
|
902
|
-
},
|
|
943
|
+
}, BROKEN_STREAM_AUTO_SKIP_DELAY_MS);
|
|
903
944
|
return;
|
|
904
945
|
}
|
|
905
946
|
setTuningStation(null);
|
|
@@ -923,6 +964,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
923
964
|
}
|
|
924
965
|
}, [playStation, store]);
|
|
925
966
|
playStationRef.current = playStation;
|
|
967
|
+
const cancelPendingAutoSkip = useCallback(() => {
|
|
968
|
+
if (!skipBrokenTimerRef.current)
|
|
969
|
+
return;
|
|
970
|
+
clearTimeout(skipBrokenTimerRef.current);
|
|
971
|
+
skipBrokenTimerRef.current = null;
|
|
972
|
+
setTuningStation(null);
|
|
973
|
+
setMessage('Auto-skip canceled. Choose a station and press Enter.');
|
|
974
|
+
}, []);
|
|
926
975
|
const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
|
|
927
976
|
if (transientFooterMessageTimerRef.current) {
|
|
928
977
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
@@ -1045,7 +1094,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1045
1094
|
}, [showTransientMessage, updateSettings]);
|
|
1046
1095
|
const cycleReceiverStyle = useCallback(() => {
|
|
1047
1096
|
const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
|
|
1048
|
-
setPulse(0);
|
|
1049
1097
|
updateSettings({ receiverStyle });
|
|
1050
1098
|
showTransientMessage(`Receiver style: ${receiverStyle}`);
|
|
1051
1099
|
}, [showTransientMessage, updateSettings]);
|
|
@@ -1066,7 +1114,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1066
1114
|
const current = stationContextsRef.current.nearby;
|
|
1067
1115
|
setStationContextFor('nearby', {
|
|
1068
1116
|
...current,
|
|
1069
|
-
subtitle: 'Location lookup off · showing the last loaded results'
|
|
1117
|
+
subtitle: 'Location lookup off · showing the last loaded results',
|
|
1118
|
+
error: undefined
|
|
1070
1119
|
});
|
|
1071
1120
|
}
|
|
1072
1121
|
}, [loadNearby, setStationContextFor, updateSettings]);
|
|
@@ -1178,6 +1227,59 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1178
1227
|
updateSettings({ [key]: next });
|
|
1179
1228
|
setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
|
|
1180
1229
|
}, [updateSettings]);
|
|
1230
|
+
const toggleAgentSetting = useCallback((key) => {
|
|
1231
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1232
|
+
const next = !agentControl[key];
|
|
1233
|
+
updateSettings({ agentControl: { ...agentControl, [key]: next } });
|
|
1234
|
+
setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${next ? 'on' : 'off'}.`);
|
|
1235
|
+
}, [updateSettings]);
|
|
1236
|
+
const setAgentIntegrationEnabled = useCallback(async () => {
|
|
1237
|
+
if (configuringMcpRef.current) {
|
|
1238
|
+
setMessage('Agent integration setup is already in progress.');
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
configuringMcpRef.current = true;
|
|
1242
|
+
const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
|
|
1243
|
+
setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
|
|
1244
|
+
try {
|
|
1245
|
+
const results = await mcpConfigurator(enabled, mcpRuntime(), null);
|
|
1246
|
+
const failed = results.filter(item => item.status === 'failed');
|
|
1247
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1248
|
+
updateSettings({ agentControl: { ...agentControl, enabled } });
|
|
1249
|
+
setMessage(integrationResultMessage(enabled, results, failed));
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
if (!enabled) {
|
|
1253
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1254
|
+
updateSettings({ agentControl: { ...agentControl, enabled: false } });
|
|
1255
|
+
}
|
|
1256
|
+
setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1257
|
+
}
|
|
1258
|
+
finally {
|
|
1259
|
+
configuringMcpRef.current = false;
|
|
1260
|
+
}
|
|
1261
|
+
}, [mcpConfigurator, updateSettings]);
|
|
1262
|
+
const repairMcpIntegrations = useCallback(async () => {
|
|
1263
|
+
if (configuringMcpRef.current) {
|
|
1264
|
+
setMessage('Agent integration setup is already in progress.');
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
configuringMcpRef.current = true;
|
|
1268
|
+
setMessage('Checking and repairing agent integrations...');
|
|
1269
|
+
try {
|
|
1270
|
+
const results = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1271
|
+
const failed = results.filter(item => item.status === 'failed');
|
|
1272
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1273
|
+
updateSettings({ agentControl: { ...agentControl, enabled: true } });
|
|
1274
|
+
setMessage(integrationResultMessage(true, results, failed));
|
|
1275
|
+
}
|
|
1276
|
+
catch (error) {
|
|
1277
|
+
setMessage(`MCP repair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1278
|
+
}
|
|
1279
|
+
finally {
|
|
1280
|
+
configuringMcpRef.current = false;
|
|
1281
|
+
}
|
|
1282
|
+
}, [mcpConfigurator, updateSettings]);
|
|
1181
1283
|
const cycleSleepTimer = useCallback(() => {
|
|
1182
1284
|
const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
|
|
1183
1285
|
const next = nextSleepTimerMinutes(currentMinutes);
|
|
@@ -1187,6 +1289,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1187
1289
|
go('library', { resetSelection: false });
|
|
1188
1290
|
}, [go]);
|
|
1189
1291
|
const selectedStation = displayStations[selected] ?? null;
|
|
1292
|
+
const selectedStationForInput = useCallback(() => displayStationsRef.current[selectedRef.current] ?? null, []);
|
|
1293
|
+
const alarmTui = useAlarmTui({
|
|
1294
|
+
service: alarmService,
|
|
1295
|
+
store,
|
|
1296
|
+
library,
|
|
1297
|
+
setLibrary,
|
|
1298
|
+
screen,
|
|
1299
|
+
selected,
|
|
1300
|
+
setSelected,
|
|
1301
|
+
go,
|
|
1302
|
+
setMessage,
|
|
1303
|
+
playingStation,
|
|
1304
|
+
previewStation: alarmPreview ?? (station => playStation(station))
|
|
1305
|
+
});
|
|
1306
|
+
for (const alarmScreen of ['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']) {
|
|
1307
|
+
itemCountsRef.current[alarmScreen] = alarmTui.itemCount(alarmScreen) ?? 0;
|
|
1308
|
+
}
|
|
1190
1309
|
const openScreen = useCallback((target) => {
|
|
1191
1310
|
if (target === 'explore') {
|
|
1192
1311
|
if (stationContextsRef.current.explore.stations.length > 0) {
|
|
@@ -1250,7 +1369,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1250
1369
|
return;
|
|
1251
1370
|
}
|
|
1252
1371
|
const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
|
|
1253
|
-
if (!currentUpdateCheck
|
|
1372
|
+
if (!updateAvailableForVersion(currentUpdateCheck, installedVersion)) {
|
|
1254
1373
|
setMessage('Checking for updates...');
|
|
1255
1374
|
const latest = await refreshUpdateCheck();
|
|
1256
1375
|
if (latest.error) {
|
|
@@ -1270,12 +1389,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1270
1389
|
const result = await installUpdate(command.command);
|
|
1271
1390
|
installingUpdateRef.current = false;
|
|
1272
1391
|
if (result.ok) {
|
|
1273
|
-
|
|
1392
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1393
|
+
if (agentControl.enabled) {
|
|
1394
|
+
try {
|
|
1395
|
+
const repaired = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1396
|
+
const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.';
|
|
1397
|
+
setMessage(`Update installed.${suffix} Restart RadioCLI and open agent clients.`);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then restart RadioCLI and open agent clients.');
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
setMessage('Update installed. Restart RadioCLI to use the new version.');
|
|
1405
|
+
}
|
|
1274
1406
|
return;
|
|
1275
1407
|
}
|
|
1276
1408
|
const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
|
|
1277
1409
|
setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
|
|
1278
|
-
}, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
|
|
1410
|
+
}, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
|
|
1279
1411
|
const executeCommand = useCommandExecutor({
|
|
1280
1412
|
beginLearningTransportKey,
|
|
1281
1413
|
countries,
|
|
@@ -1327,10 +1459,165 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1327
1459
|
void playStation(nextStation, { queue });
|
|
1328
1460
|
}
|
|
1329
1461
|
}, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
|
|
1462
|
+
agentHandlerRef.current = async (command) => {
|
|
1463
|
+
const sessionStatus = () => ({
|
|
1464
|
+
owner: 'tui',
|
|
1465
|
+
playback: player.getState(),
|
|
1466
|
+
station: playingStationRef.current,
|
|
1467
|
+
queue: playbackQueueRef.current?.stations ?? [],
|
|
1468
|
+
output: {
|
|
1469
|
+
preferredBackend: settingsRef.current.preferredBackend,
|
|
1470
|
+
preferredAirPlayDevice: settingsRef.current.preferredAirPlayDevice
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
const respond = (message, ok = true, data) => ({ ok, message, status: sessionStatus(), ...(data ? { data } : {}) });
|
|
1474
|
+
if (command.type === 'status')
|
|
1475
|
+
return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
|
|
1476
|
+
if (command.type === 'play') {
|
|
1477
|
+
if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state)) {
|
|
1478
|
+
return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
|
|
1479
|
+
}
|
|
1480
|
+
const queue = { title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station] };
|
|
1481
|
+
await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying });
|
|
1482
|
+
const state = player.getState();
|
|
1483
|
+
const playingRequestedStation = playingStationRef.current
|
|
1484
|
+
&& stationKey(playingStationRef.current) === stationKey(command.station)
|
|
1485
|
+
&& state.state === 'playing'
|
|
1486
|
+
&& state.ready;
|
|
1487
|
+
return playingRequestedStation
|
|
1488
|
+
? respond(`Playing ${command.station.name}.`)
|
|
1489
|
+
: respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
|
|
1490
|
+
}
|
|
1491
|
+
if (command.type === 'pause') {
|
|
1492
|
+
const control = await player.pause();
|
|
1493
|
+
return respond(control.message ?? 'Paused.', control.ok);
|
|
1494
|
+
}
|
|
1495
|
+
if (command.type === 'resume') {
|
|
1496
|
+
const control = await player.resume();
|
|
1497
|
+
return respond(control.message ?? 'Resumed.', control.ok);
|
|
1498
|
+
}
|
|
1499
|
+
if (command.type === 'stop') {
|
|
1500
|
+
tuneRequestRef.current += 1;
|
|
1501
|
+
setLibrary(store.finishActiveListeningSession());
|
|
1502
|
+
await player.stop();
|
|
1503
|
+
playingStationRef.current = null;
|
|
1504
|
+
setPlayingStation(null);
|
|
1505
|
+
setTuningStation(null);
|
|
1506
|
+
playbackQueueRef.current = null;
|
|
1507
|
+
return respond('Stopped RadioCLI.');
|
|
1508
|
+
}
|
|
1509
|
+
if (command.type === 'alarm-preempt') {
|
|
1510
|
+
tuneRequestRef.current += 1;
|
|
1511
|
+
setLibrary(store.finishActiveListeningSession());
|
|
1512
|
+
await player.stop();
|
|
1513
|
+
playingStationRef.current = null;
|
|
1514
|
+
setPlayingStation(null);
|
|
1515
|
+
setTuningStation(null);
|
|
1516
|
+
playbackQueueRef.current = null;
|
|
1517
|
+
return respond('Interactive playback yielded to the alarm.');
|
|
1518
|
+
}
|
|
1519
|
+
if (command.type === 'set-volume') {
|
|
1520
|
+
const control = await player.setVolume(clampVolume(command.volume));
|
|
1521
|
+
if (control.ok)
|
|
1522
|
+
updateSettings({ volume: player.getState().volume });
|
|
1523
|
+
return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
|
|
1524
|
+
}
|
|
1525
|
+
if (command.type === 'set-muted') {
|
|
1526
|
+
const control = await player.setMuted(command.muted);
|
|
1527
|
+
return respond(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
|
|
1528
|
+
}
|
|
1529
|
+
if (command.type === 'set-favorite') {
|
|
1530
|
+
const station = command.station ?? playingStationRef.current;
|
|
1531
|
+
if (!station)
|
|
1532
|
+
return respond('No active station to favorite.', false);
|
|
1533
|
+
const current = store.isFavorite(station);
|
|
1534
|
+
if (current !== command.favorite)
|
|
1535
|
+
toggleFavorite(station);
|
|
1536
|
+
return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
|
|
1537
|
+
}
|
|
1538
|
+
if (command.type === 'airplay-list') {
|
|
1539
|
+
const devices = await refreshAirPlayTargets(false);
|
|
1540
|
+
return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
|
|
1541
|
+
}
|
|
1542
|
+
if (command.type === 'airplay-select') {
|
|
1543
|
+
const devices = await refreshAirPlayTargets(false);
|
|
1544
|
+
const device = devices.find(item => item.id === command.deviceId);
|
|
1545
|
+
if (!device)
|
|
1546
|
+
return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
|
|
1547
|
+
if (device.local) {
|
|
1548
|
+
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1549
|
+
if (!backend)
|
|
1550
|
+
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1551
|
+
updateSettings({ preferredBackend: backend });
|
|
1552
|
+
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1553
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1554
|
+
}
|
|
1555
|
+
return respond(`Audio output set to this device (${backend}).`);
|
|
1556
|
+
}
|
|
1557
|
+
if (!isAirPlayBackendAvailable(availableBackends))
|
|
1558
|
+
return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
|
|
1559
|
+
updateSettings({ preferredAirPlayDevice: device.id });
|
|
1560
|
+
updateSettings({ preferredBackend: 'airplay' });
|
|
1561
|
+
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1562
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1563
|
+
}
|
|
1564
|
+
return respond(`Audio output set to AirPlay receiver ${device.name}.`);
|
|
1565
|
+
}
|
|
1566
|
+
if (command.type === 'airplay-local') {
|
|
1567
|
+
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1568
|
+
if (!backend)
|
|
1569
|
+
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1570
|
+
updateSettings({ preferredBackend: backend });
|
|
1571
|
+
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1572
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1573
|
+
}
|
|
1574
|
+
return respond(`Audio output set to this device (${backend}).`);
|
|
1575
|
+
}
|
|
1576
|
+
if (command.type === 'airplay-passcode') {
|
|
1577
|
+
const control = player.submitAirPlayPasscode(command.code);
|
|
1578
|
+
return respond(control.message ?? 'AirPlay code sent.', control.ok);
|
|
1579
|
+
}
|
|
1580
|
+
if (command.type === 'update-settings') {
|
|
1581
|
+
updateSettings(command.settings);
|
|
1582
|
+
return respond('RadioCLI settings updated.');
|
|
1583
|
+
}
|
|
1584
|
+
const active = playingStationRef.current;
|
|
1585
|
+
const queue = playbackQueueRef.current;
|
|
1586
|
+
if (!active || !queue?.stations.length)
|
|
1587
|
+
return respond('No playback queue is available.', false);
|
|
1588
|
+
const currentIndex = queue.stations.findIndex(item => stationKey(item) === stationKey(active));
|
|
1589
|
+
const delta = command.type === 'next' ? 1 : -1;
|
|
1590
|
+
const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
|
|
1591
|
+
if (!next)
|
|
1592
|
+
return respond('No adjacent station is available.', false);
|
|
1593
|
+
await playStation(next, { queue });
|
|
1594
|
+
return respond(`Playing ${next.name}.`);
|
|
1595
|
+
};
|
|
1596
|
+
useEffect(() => {
|
|
1597
|
+
if (!stdin.isTTY || !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
|
|
1598
|
+
return;
|
|
1599
|
+
let disposed = false;
|
|
1600
|
+
let close;
|
|
1601
|
+
void startRadioSession(command => agentHandlerRef.current(command)).then(async (session) => {
|
|
1602
|
+
if (disposed) {
|
|
1603
|
+
await session.close();
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
close = session.close;
|
|
1607
|
+
if (initialAgentCommand)
|
|
1608
|
+
await agentHandlerRef.current(initialAgentCommand);
|
|
1609
|
+
}).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
|
|
1610
|
+
return () => {
|
|
1611
|
+
disposed = true;
|
|
1612
|
+
if (close)
|
|
1613
|
+
void close();
|
|
1614
|
+
};
|
|
1615
|
+
}, [initialAgentCommand, library.settings.agentControl?.enabled, stdin]);
|
|
1330
1616
|
useAppInput({
|
|
1331
1617
|
adjustVolume,
|
|
1332
1618
|
airPlayCode,
|
|
1333
1619
|
canEnterAirPlayCode,
|
|
1620
|
+
cancelPendingAutoSkip,
|
|
1334
1621
|
copyStationUrl,
|
|
1335
1622
|
openStationHomepage,
|
|
1336
1623
|
beginLearningTransportKey,
|
|
@@ -1354,7 +1641,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1354
1641
|
openAdjacentTab,
|
|
1355
1642
|
openAirPlayCode,
|
|
1356
1643
|
openAirPlaySettings,
|
|
1644
|
+
openSettingsPage,
|
|
1645
|
+
closeSettingsPage,
|
|
1357
1646
|
openScreen,
|
|
1647
|
+
handleAlarmInput: alarmTui.handleInput,
|
|
1648
|
+
openAlarmForStation: alarmTui.openForStation,
|
|
1649
|
+
openActiveAlarms: alarmTui.openActive,
|
|
1358
1650
|
playAdjacent,
|
|
1359
1651
|
playStation,
|
|
1360
1652
|
player,
|
|
@@ -1367,12 +1659,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1367
1659
|
void refreshAirPlayTargets();
|
|
1368
1660
|
},
|
|
1369
1661
|
resetLearnedTransportKeys,
|
|
1662
|
+
repairMcpIntegrations,
|
|
1663
|
+
setAgentIntegrationEnabled,
|
|
1370
1664
|
runSearch,
|
|
1371
1665
|
saveLearnedTransportKey,
|
|
1372
1666
|
screen,
|
|
1667
|
+
settingsPage,
|
|
1373
1668
|
searchQuery,
|
|
1374
|
-
|
|
1375
|
-
|
|
1669
|
+
selectedRef,
|
|
1670
|
+
selectedStationForInput,
|
|
1376
1671
|
selectAirPlayDeviceAt,
|
|
1377
1672
|
setCapturingTransportAction,
|
|
1378
1673
|
setCommandMode,
|
|
@@ -1393,6 +1688,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1393
1688
|
toggleMute,
|
|
1394
1689
|
togglePause,
|
|
1395
1690
|
toggleSetting,
|
|
1691
|
+
toggleAgentSetting,
|
|
1396
1692
|
toggleNearbyLocation,
|
|
1397
1693
|
toggleDirectoryVoting,
|
|
1398
1694
|
toggleRadioGarden,
|
|
@@ -1414,7 +1710,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1414
1710
|
width: frameWidth,
|
|
1415
1711
|
spinnerFrame
|
|
1416
1712
|
});
|
|
1417
|
-
const
|
|
1713
|
+
const basePageFooter = pageFooterText({
|
|
1418
1714
|
canEnterAirPlayCode,
|
|
1419
1715
|
capturingTransportAction,
|
|
1420
1716
|
commandMode,
|
|
@@ -1422,15 +1718,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1422
1718
|
editingCountryFilter,
|
|
1423
1719
|
editingSearch,
|
|
1424
1720
|
playbackBackend: playback.backend,
|
|
1425
|
-
screen
|
|
1721
|
+
screen,
|
|
1722
|
+
settingsPage
|
|
1426
1723
|
});
|
|
1724
|
+
const pageFooter = alarmTui.activeAlarms.length > 0 && screen !== 'alarm-ringing'
|
|
1725
|
+
? `ALARM PLAYING · ! controls · ${basePageFooter}`
|
|
1726
|
+
: basePageFooter;
|
|
1427
1727
|
const pageFooterOwnsCompactRow = Boolean(commandMode ||
|
|
1428
1728
|
capturingTransportAction ||
|
|
1429
1729
|
editingCountryFilter ||
|
|
1430
1730
|
(screen === 'search' && editingSearch) ||
|
|
1431
1731
|
screen === 'airplay-code');
|
|
1432
1732
|
const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
|
|
1433
|
-
const compactFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow ? pageFooter : playbackFooter ?? pageFooter);
|
|
1434
1733
|
const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
|
|
1435
1734
|
? pageFooter
|
|
1436
1735
|
: footerPlayback.state === 'loading' && playbackFooter
|
|
@@ -1438,9 +1737,31 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1438
1737
|
: hasActiveMicroPlayback
|
|
1439
1738
|
? microPlaybackControlsText(playback.backend)
|
|
1440
1739
|
: pageFooter);
|
|
1441
|
-
const compactGlobalFooter =
|
|
1740
|
+
const compactGlobalFooter = '←/→ tabs · ? help · q quit';
|
|
1741
|
+
const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
|
|
1442
1742
|
const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
|
|
1443
|
-
|
|
1743
|
+
const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
|
|
1744
|
+
const compactStatus = footerMessage ?? message ?? playbackFooter;
|
|
1745
|
+
const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
|
|
1746
|
+
const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
|
|
1747
|
+
const microLegendWidth = Math.max(1, frameWidth - versionReserve);
|
|
1748
|
+
const microLegend = commandMode || capturingTransportAction || footerMessage || message
|
|
1749
|
+
? microFooter
|
|
1750
|
+
: microShortcutFooterText(microFooter, microLegendWidth);
|
|
1751
|
+
const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
|
|
1752
|
+
return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: Boolean(library.settings.reduceMotion), children: _jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(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: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, searchQuery: searchQuery, screen: screen, settingsPage: settingsPage, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck, alarmTui: alarmTui }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: footerText(truncate(statusRow.text, frameWidth)) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: footerText(fullLegendRows[0] ?? ' ') }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(fullLegendRows[1] ?? ' ') }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] })] })) : (_jsx(_Fragment, { children: layout.mode === 'micro' ? _jsxs(Box, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || message ? themeAccent(theme) : textMuted, children: footerText(truncate(microLegend, microLegendWidth)) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }) : _jsxs(_Fragment, { children: [compactStatus ? _jsx(Text, { color: footerMessage || message ? themeAccent(theme) : textMuted, children: footerText(truncate(compactStatus, frameWidth)) }) : null, compactLegendRows.map((row, index) => index === compactLegendRows.length - 1 ? _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(row) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
|
|
1753
|
+
}
|
|
1754
|
+
function mcpRuntime() {
|
|
1755
|
+
return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
|
|
1756
|
+
}
|
|
1757
|
+
function integrationResultMessage(enabled, results, failed) {
|
|
1758
|
+
if (failed.length > 0) {
|
|
1759
|
+
return `${enabled ? 'Agent control enabled' : 'Agent control disabled'}, but ${failed.map(item => item.client).join(', ')} need attention. Run radiocli mcp status for details.`;
|
|
1760
|
+
}
|
|
1761
|
+
const configured = results.filter(item => item.status === (enabled ? 'configured' : 'removed')).map(item => item.client);
|
|
1762
|
+
if (!enabled)
|
|
1763
|
+
return 'Agent and voice control disabled; detected MCP registrations were removed.';
|
|
1764
|
+
return `Agent and voice control ready${configured.length ? ` for ${configured.join(', ')}` : ''}. Restart open Codex or agent clients, then ask them to control RadioCLI.`;
|
|
1444
1765
|
}
|
|
1445
1766
|
function buildLibraryStations(library) {
|
|
1446
1767
|
const stations = [];
|
|
@@ -1512,6 +1833,13 @@ function formatDistanceKm(distanceKm) {
|
|
|
1512
1833
|
}
|
|
1513
1834
|
return `${Math.round(distanceKm).toLocaleString()} km`;
|
|
1514
1835
|
}
|
|
1836
|
+
function discoveryLoadError(fallback, error) {
|
|
1837
|
+
const detail = error instanceof Error ? error.message.trim() : '';
|
|
1838
|
+
if (!detail || detail === fallback || fallback.includes(detail)) {
|
|
1839
|
+
return fallback;
|
|
1840
|
+
}
|
|
1841
|
+
return `${fallback} ${detail}`;
|
|
1842
|
+
}
|
|
1515
1843
|
function nextAvailablePlaybackBackend(current, backends) {
|
|
1516
1844
|
const options = ['auto'];
|
|
1517
1845
|
for (const backend of ['mpv', 'ffplay', 'vlc', 'airplay']) {
|
|
@@ -1557,6 +1885,12 @@ function librarySubtitle(library) {
|
|
|
1557
1885
|
function mouseVisibleRows(screen, layout) {
|
|
1558
1886
|
if (screen === 'help')
|
|
1559
1887
|
return Math.max(3, layout.contentRows - 4);
|
|
1888
|
+
if (screen === 'alarms' || screen === 'alarm-editor')
|
|
1889
|
+
return Math.max(1, layout.contentRows - 7);
|
|
1890
|
+
if (screen === 'alarm-picker')
|
|
1891
|
+
return Math.max(1, layout.contentRows - 5);
|
|
1892
|
+
if (screen === 'alarm-ringing')
|
|
1893
|
+
return Math.max(1, layout.contentRows - 7);
|
|
1560
1894
|
if (layout.compact)
|
|
1561
1895
|
return Math.max(1, layout.contentRows - 3);
|
|
1562
1896
|
if (screen === 'countries')
|