@ciphore/radiocli 0.2.2 → 0.2.4
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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -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 +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- 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 +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
package/dist/ui/App.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
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 {
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize } from 'ink';
|
|
4
5
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
5
6
|
import { PlayerController } from '../player/player-controller.js';
|
|
6
|
-
import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
7
|
+
import { airPlayMacOSOnlyMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
7
8
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
9
|
+
import { defaultAgentControlSettings } from '../types.js';
|
|
8
10
|
import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
|
|
9
11
|
import { DisplayContext, resolveDisplayMode } from './display-context.js';
|
|
10
|
-
import { homeItems,
|
|
12
|
+
import { homeItems, settingsItemsForPage } from './screen-items.js';
|
|
11
13
|
import { AppContent } from './AppContent.js';
|
|
12
14
|
import { TopTabs } from './components/TopTabs.js';
|
|
13
15
|
import { computeTerminalLayout } from './layout.js';
|
|
14
|
-
import {
|
|
16
|
+
import { truncate } from './format.js';
|
|
15
17
|
import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
|
|
16
18
|
import { balancedFooterLegendRows, fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, microShortcutFooterText, pageFooterText } from './page-footer.js';
|
|
17
19
|
import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
|
|
@@ -19,20 +21,25 @@ import { useAppInput } from './use-app-input.js';
|
|
|
19
21
|
import { useCommandExecutor } from './use-command-executor.js';
|
|
20
22
|
import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
|
|
21
23
|
import { isAirPlayBackendAvailable } from './airplay-settings.js';
|
|
24
|
+
import { networkPolicy } from '../platform/network.js';
|
|
22
25
|
import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
23
26
|
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
27
|
+
import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
24
28
|
import { appVersion } from '../version.js';
|
|
25
|
-
import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
|
|
29
|
+
import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
|
|
26
30
|
import { helpItemCount } from './help-content.js';
|
|
27
31
|
import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
|
|
28
32
|
import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
|
|
29
33
|
import { useAlarmTui } from './use-alarm-tui.js';
|
|
30
34
|
import { toAsciiSafe } from './ascii.js';
|
|
31
35
|
import { registerTuiPresence } from '../alarms/tui-presence.js';
|
|
36
|
+
import { configureMcpIntegrations } from '../agent/mcp-install.js';
|
|
37
|
+
import { startRadioSession } from '../agent/session.js';
|
|
32
38
|
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';
|
|
33
39
|
import { ReceiverAnimationProvider } from './receiver-animation.js';
|
|
40
|
+
import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
|
|
34
41
|
const LOADING_SPINNER_MS = 120;
|
|
35
|
-
const
|
|
42
|
+
const TRANSIENT_NOTICE_MS = 4500;
|
|
36
43
|
const LISTENING_HEARTBEAT_MS = 30_000;
|
|
37
44
|
const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
38
45
|
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
@@ -44,17 +51,20 @@ const settingToggleLabel = {
|
|
|
44
51
|
transparentBackground: 'Transparent background',
|
|
45
52
|
asciiMode: 'ASCII-safe display',
|
|
46
53
|
reduceMotion: 'Reduce motion',
|
|
47
|
-
mouseSupport: 'Mouse and trackpad scrolling'
|
|
54
|
+
mouseSupport: 'Mouse and trackpad scrolling',
|
|
55
|
+
automaticUpdateChecks: 'Automatic update checks'
|
|
48
56
|
};
|
|
49
|
-
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview }) {
|
|
57
|
+
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform }) {
|
|
50
58
|
const { exit } = useApp();
|
|
51
59
|
const { stdin } = useStdin();
|
|
52
60
|
const { stdout } = useStdout();
|
|
53
61
|
const { columns, rows } = useWindowSize();
|
|
62
|
+
const screenReader = useIsScreenReaderEnabled();
|
|
54
63
|
const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
|
|
55
64
|
const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
|
|
56
65
|
const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
|
|
57
66
|
const installedVersion = useMemo(() => appVersion(), []);
|
|
67
|
+
const airPlaySupported = isAirPlayPlatformSupported(platform);
|
|
58
68
|
const [library, setLibrary] = useState(() => store.snapshot());
|
|
59
69
|
const settingsRef = useRef(library.settings);
|
|
60
70
|
settingsRef.current = library.settings;
|
|
@@ -64,7 +74,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
64
74
|
const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
|
|
65
75
|
const [screen, setScreen] = useState('home');
|
|
66
76
|
const [selected, setSelected] = useState(0);
|
|
77
|
+
const [settingsPage, setSettingsPage] = useState('root');
|
|
67
78
|
const [message, setMessage] = useState(null);
|
|
79
|
+
const [persistenceWarning, setPersistenceWarning] = useState(null);
|
|
80
|
+
const [presenceWarning, setPresenceWarning] = useState(null);
|
|
68
81
|
const [footerMessage, setFooterMessage] = useState(null);
|
|
69
82
|
const [countries, setCountries] = useState([]);
|
|
70
83
|
const [countryFilter, setCountryFilter] = useState('');
|
|
@@ -88,8 +101,27 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
88
101
|
const [sleepUntil, setSleepUntil] = useState(null);
|
|
89
102
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
90
103
|
const [capturingTransportAction, setCapturingTransportAction] = useState(null);
|
|
104
|
+
const reportActionError = useCallback((error) => {
|
|
105
|
+
setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
|
|
106
|
+
}, []);
|
|
107
|
+
// Optional history cannot change the result of playback or navigation. Keep
|
|
108
|
+
// its warning until an explicit save succeeds; a no-op checkpoint proves nothing.
|
|
109
|
+
const persistLibrary = useCallback((write) => {
|
|
110
|
+
try {
|
|
111
|
+
const nextLibrary = write();
|
|
112
|
+
setLibrary(nextLibrary);
|
|
113
|
+
return nextLibrary;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error));
|
|
117
|
+
setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`);
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
91
121
|
const announcedUpdateRef = useRef(false);
|
|
122
|
+
const automaticUpdateCheckStartedRef = useRef(false);
|
|
92
123
|
const installingUpdateRef = useRef(false);
|
|
124
|
+
const configuringMcpRef = useRef(false);
|
|
93
125
|
const displayStationsRef = useRef([]);
|
|
94
126
|
const playbackQueueRef = useRef(null);
|
|
95
127
|
const lastRawTransportAtRef = useRef(0);
|
|
@@ -99,6 +131,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
99
131
|
const playStationRef = useRef(() => undefined);
|
|
100
132
|
const screenRef = useRef(screen);
|
|
101
133
|
const selectedRef = useRef(selected);
|
|
134
|
+
const settingsPageRef = useRef(settingsPage);
|
|
135
|
+
const selectedBySettingsPageRef = useRef({});
|
|
102
136
|
const selectedByScreenRef = useRef({});
|
|
103
137
|
const stationContextsRef = useRef(stationContexts);
|
|
104
138
|
const lastStationContextKeyRef = useRef('explore');
|
|
@@ -118,8 +152,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
118
152
|
const nearbyRequestRef = useRef(0);
|
|
119
153
|
const skipBrokenTimerRef = useRef(null);
|
|
120
154
|
const volumeRequestRef = useRef(0);
|
|
155
|
+
const agentHandlerRef = useRef(async () => {
|
|
156
|
+
throw new Error('RadioCLI is still starting.');
|
|
157
|
+
});
|
|
158
|
+
const showTransientMessage = useCallback((nextMessage) => {
|
|
159
|
+
if (transientMessageTimerRef.current) {
|
|
160
|
+
clearTimeout(transientMessageTimerRef.current);
|
|
161
|
+
}
|
|
162
|
+
setMessage(nextMessage);
|
|
163
|
+
transientMessageTimerRef.current = setTimeout(() => {
|
|
164
|
+
setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
165
|
+
transientMessageTimerRef.current = null;
|
|
166
|
+
}, TRANSIENT_NOTICE_MS);
|
|
167
|
+
}, []);
|
|
121
168
|
const theme = library.settings.theme;
|
|
122
|
-
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
169
|
+
const displayMode = useMemo(() => resolveDisplayMode(library.settings, process.env, { screenReader, isTTY: Boolean(stdout.isTTY), colorDepth: stdout.getColorDepth?.() }), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout]);
|
|
123
170
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
124
171
|
const diagnostics = player.diagnostics();
|
|
125
172
|
const filterLabel = formatFilterLabel(filters);
|
|
@@ -141,6 +188,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
141
188
|
}), [library, libraryStations, stationContexts]);
|
|
142
189
|
screenRef.current = screen;
|
|
143
190
|
selectedRef.current = selected;
|
|
191
|
+
settingsPageRef.current = settingsPage;
|
|
144
192
|
loadingStationsRef.current = loadingStations;
|
|
145
193
|
stationContextsRef.current = activeStationContexts;
|
|
146
194
|
exploreCursorRef.current = exploreCursor;
|
|
@@ -171,7 +219,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
171
219
|
'alarm-ringing': 0,
|
|
172
220
|
'airplay-settings': 0,
|
|
173
221
|
'airplay-code': 1,
|
|
174
|
-
settings:
|
|
222
|
+
settings: settingsItemsForPage(settingsPage).length,
|
|
175
223
|
help: helpItemCount
|
|
176
224
|
});
|
|
177
225
|
itemCountsRef.current = {
|
|
@@ -191,7 +239,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
191
239
|
'alarm-ringing': 0,
|
|
192
240
|
'airplay-settings': availableAirPlayDevices.length,
|
|
193
241
|
'airplay-code': 1,
|
|
194
|
-
settings:
|
|
242
|
+
settings: settingsItemsForPage(settingsPage).length,
|
|
195
243
|
help: helpItemCount
|
|
196
244
|
};
|
|
197
245
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
@@ -207,12 +255,31 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
207
255
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
208
256
|
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
209
257
|
const frameWidth = layout.frameWidth;
|
|
210
|
-
const mouseReportingActive = !
|
|
258
|
+
const mouseReportingActive = !displayMode.screenReader &&
|
|
259
|
+
process.env.TERM?.toLowerCase() !== 'dumb' &&
|
|
260
|
+
!commandMode &&
|
|
211
261
|
!capturingTransportAction &&
|
|
212
262
|
!editingCountryFilter &&
|
|
213
263
|
shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
|
|
214
264
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
215
|
-
useEffect(() =>
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!stdin.isTTY)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
const unregister = registerTuiPresence();
|
|
270
|
+
return () => {
|
|
271
|
+
try {
|
|
272
|
+
unregister();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
setPresenceWarning('Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.');
|
|
281
|
+
}
|
|
282
|
+
}, [stdin]);
|
|
216
283
|
const playingStationRef = useRef(null);
|
|
217
284
|
playingStationRef.current = playingStation;
|
|
218
285
|
const activeListeningStationRef = useRef(null);
|
|
@@ -224,24 +291,24 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
224
291
|
const key = stationKey(station);
|
|
225
292
|
if (activeListeningStationRef.current !== key) {
|
|
226
293
|
activeListeningStationRef.current = key;
|
|
227
|
-
|
|
294
|
+
persistLibrary(() => store.startListeningSession(station));
|
|
228
295
|
}
|
|
229
296
|
return;
|
|
230
297
|
}
|
|
231
298
|
if (activeListeningStationRef.current) {
|
|
232
299
|
activeListeningStationRef.current = null;
|
|
233
|
-
|
|
300
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
234
301
|
}
|
|
235
|
-
}, [playback.ready, playback.state, playingStation, store]);
|
|
302
|
+
}, [persistLibrary, playback.ready, playback.state, playingStation, store]);
|
|
236
303
|
useEffect(() => {
|
|
237
304
|
if (playback.state !== 'playing' || !playback.ready || !playingStation) {
|
|
238
305
|
return;
|
|
239
306
|
}
|
|
240
307
|
const timer = setInterval(() => {
|
|
241
|
-
|
|
308
|
+
persistLibrary(() => store.checkpointActiveListeningSession());
|
|
242
309
|
}, LISTENING_HEARTBEAT_MS);
|
|
243
310
|
return () => clearInterval(timer);
|
|
244
|
-
}, [playback.ready, playback.state, playingStation, store]);
|
|
311
|
+
}, [persistLibrary, playback.ready, playback.state, playingStation, store]);
|
|
245
312
|
useEffect(() => player.onMetadata(metadata => {
|
|
246
313
|
setNowPlaying(metadata);
|
|
247
314
|
const station = playingStationRef.current;
|
|
@@ -250,9 +317,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
250
317
|
if (lastRecordedTrackRef.current === trackKey)
|
|
251
318
|
return;
|
|
252
319
|
lastRecordedTrackRef.current = trackKey;
|
|
253
|
-
|
|
320
|
+
persistLibrary(() => store.recordTrack(station, metadata.title));
|
|
254
321
|
}
|
|
255
|
-
}), [player, store]);
|
|
322
|
+
}), [persistLibrary, player, store]);
|
|
256
323
|
useEffect(() => {
|
|
257
324
|
if (!stdout.isTTY || !mouseReportingActive) {
|
|
258
325
|
return;
|
|
@@ -270,7 +337,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
270
337
|
}, [renderedStationContextKey, screen, selected]);
|
|
271
338
|
useEffect(() => {
|
|
272
339
|
if (footerPlayback.state !== 'loading' ||
|
|
273
|
-
|
|
340
|
+
displayMode.reduceMotion ||
|
|
274
341
|
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
275
342
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
276
343
|
setSpinnerFrame(0);
|
|
@@ -278,7 +345,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
278
345
|
}
|
|
279
346
|
const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
|
|
280
347
|
return () => clearInterval(timer);
|
|
281
|
-
}, [footerPlayback.state,
|
|
348
|
+
}, [footerPlayback.state, displayMode.reduceMotion]);
|
|
282
349
|
useEffect(() => {
|
|
283
350
|
if ((screen === 'countries' || screen === 'map') &&
|
|
284
351
|
countries.length === 0 &&
|
|
@@ -317,27 +384,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
317
384
|
const delayMs = sleepUntil - Date.now();
|
|
318
385
|
if (delayMs <= 0) {
|
|
319
386
|
tuneRequestRef.current += 1;
|
|
320
|
-
|
|
321
|
-
void player.stop();
|
|
387
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
388
|
+
void player.stop().catch(reportActionError);
|
|
322
389
|
setSleepUntil(null);
|
|
323
390
|
return;
|
|
324
391
|
}
|
|
325
392
|
const timer = setTimeout(() => {
|
|
326
393
|
tuneRequestRef.current += 1;
|
|
327
|
-
|
|
328
|
-
void player.stop();
|
|
394
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
395
|
+
void player.stop().catch(reportActionError);
|
|
329
396
|
setSleepUntil(null);
|
|
330
397
|
}, delayMs);
|
|
331
398
|
return () => clearTimeout(timer);
|
|
332
|
-
}, [player, sleepUntil, store]);
|
|
399
|
+
}, [persistLibrary, player, reportActionError, sleepUntil, store]);
|
|
333
400
|
useEffect(() => {
|
|
334
401
|
const backends = player.refreshDetectedBackends();
|
|
335
402
|
setAvailableBackends(backends);
|
|
336
|
-
|
|
403
|
+
const network = networkPolicy();
|
|
404
|
+
if (airPlaySupported && !network.offline && !network.lowBandwidth) {
|
|
405
|
+
void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
|
|
406
|
+
}
|
|
337
407
|
if (backends.length === 0) {
|
|
338
408
|
setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
|
|
339
409
|
}
|
|
340
|
-
}, [player]);
|
|
410
|
+
}, [airPlaySupported, player]);
|
|
341
411
|
const refreshProviderHealth = useCallback(() => {
|
|
342
412
|
providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
|
|
343
413
|
}, [providers]);
|
|
@@ -345,14 +415,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
345
415
|
refreshProviderHealth();
|
|
346
416
|
}, [refreshProviderHealth]);
|
|
347
417
|
const refreshUpdateCheck = useCallback(async () => {
|
|
348
|
-
const updateCheck = await
|
|
349
|
-
|
|
418
|
+
const updateCheck = await updateChecker({ currentVersion: installedVersion });
|
|
419
|
+
persistLibrary(() => store.updateCheckState(updateCheck));
|
|
350
420
|
return updateCheck;
|
|
351
|
-
}, [installedVersion, store]);
|
|
421
|
+
}, [installedVersion, persistLibrary, store, updateChecker]);
|
|
352
422
|
useEffect(() => {
|
|
353
|
-
if (
|
|
423
|
+
if (automaticUpdateCheckStartedRef.current ||
|
|
424
|
+
!automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
|
|
354
425
|
return;
|
|
355
426
|
}
|
|
427
|
+
automaticUpdateCheckStartedRef.current = true;
|
|
356
428
|
let cancelled = false;
|
|
357
429
|
void refreshUpdateCheck().then(updateCheck => {
|
|
358
430
|
if (cancelled) {
|
|
@@ -362,11 +434,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
362
434
|
announcedUpdateRef.current = true;
|
|
363
435
|
setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
|
|
364
436
|
}
|
|
437
|
+
}).catch(error => {
|
|
438
|
+
if (!cancelled)
|
|
439
|
+
reportActionError(error);
|
|
365
440
|
});
|
|
366
441
|
return () => {
|
|
367
442
|
cancelled = true;
|
|
368
443
|
};
|
|
369
|
-
}, [library.
|
|
444
|
+
}, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]);
|
|
370
445
|
const setStationContextFor = useCallback((key, context) => {
|
|
371
446
|
setStationContexts(current => ({ ...current, [key]: context }));
|
|
372
447
|
}, []);
|
|
@@ -376,6 +451,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
376
451
|
const nextLibrary = store.updateSettings(settings);
|
|
377
452
|
settingsRef.current = nextLibrary.settings;
|
|
378
453
|
setLibrary(nextLibrary);
|
|
454
|
+
setPersistenceWarning(null);
|
|
379
455
|
return nextLibrary;
|
|
380
456
|
}, [store]);
|
|
381
457
|
useEffect(() => {
|
|
@@ -387,9 +463,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
387
463
|
setMessage(`${selectedAirPlayDevice.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
|
|
388
464
|
return;
|
|
389
465
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
466
|
+
try {
|
|
467
|
+
updateSettings({ preferredBackend });
|
|
468
|
+
setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
reportActionError(error);
|
|
472
|
+
}
|
|
473
|
+
}, [availableBackends, reportActionError, selectedAirPlayDevice, updateSettings]);
|
|
393
474
|
const updateMediaKeys = useCallback((mediaKeys) => {
|
|
394
475
|
updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
|
|
395
476
|
}, [updateSettings]);
|
|
@@ -414,6 +495,15 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
414
495
|
const destination = screenRef.current === 'help' && next === 'home'
|
|
415
496
|
? helpReturnScreenRef.current
|
|
416
497
|
: next;
|
|
498
|
+
if (destination === 'settings' &&
|
|
499
|
+
screenRef.current !== 'settings' &&
|
|
500
|
+
screenRef.current !== 'airplay-settings' &&
|
|
501
|
+
screenRef.current !== 'airplay-code') {
|
|
502
|
+
settingsPageRef.current = 'root';
|
|
503
|
+
setSettingsPage('root');
|
|
504
|
+
itemCountsRef.current.settings = settingsItemsForPage('root').length;
|
|
505
|
+
selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
|
|
506
|
+
}
|
|
417
507
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
418
508
|
const remembered = selectedByScreenRef.current[destination] ?? 0;
|
|
419
509
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
@@ -423,10 +513,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
423
513
|
setMessage(null);
|
|
424
514
|
}
|
|
425
515
|
}, []);
|
|
516
|
+
const openSettingsPage = useCallback((next) => {
|
|
517
|
+
selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
|
|
518
|
+
settingsPageRef.current = next;
|
|
519
|
+
setSettingsPage(next);
|
|
520
|
+
const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
|
|
521
|
+
selectedRef.current = nextSelection;
|
|
522
|
+
setSelected(nextSelection);
|
|
523
|
+
setMessage(null);
|
|
524
|
+
}, []);
|
|
525
|
+
const closeSettingsPage = useCallback(() => {
|
|
526
|
+
if (settingsPageRef.current === 'root') {
|
|
527
|
+
go('home');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
openSettingsPage('root');
|
|
531
|
+
}, [go, openSettingsPage]);
|
|
426
532
|
const openAirPlayCode = useCallback(() => {
|
|
533
|
+
if (!airPlaySupported) {
|
|
534
|
+
showTransientMessage(airPlayMacOSOnlyMessage);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
427
537
|
setAirPlayCode('');
|
|
428
538
|
go('airplay-code', { resetSelection: true, clearMessage: false });
|
|
429
|
-
}, [go]);
|
|
539
|
+
}, [airPlaySupported, go, showTransientMessage]);
|
|
430
540
|
useEffect(() => {
|
|
431
541
|
if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
|
|
432
542
|
openAirPlayCode();
|
|
@@ -434,13 +544,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
434
544
|
}, [openAirPlayCode, playback.backend, playback.message]);
|
|
435
545
|
const shutdown = useCallback(() => {
|
|
436
546
|
tuneRequestRef.current += 1;
|
|
437
|
-
store.finishActiveListeningSession();
|
|
438
|
-
player.stop().finally(exit);
|
|
439
|
-
}, [exit, player, store]);
|
|
547
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
548
|
+
void player.stop().catch(reportActionError).finally(exit);
|
|
549
|
+
}, [exit, persistLibrary, player, reportActionError, store]);
|
|
440
550
|
useEffect(() => {
|
|
441
551
|
const finishForSignal = () => {
|
|
442
|
-
store.finishActiveListeningSession();
|
|
443
|
-
void player.stop().finally(() => process.exit(0));
|
|
552
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
553
|
+
void player.stop().catch(reportActionError).finally(() => process.exit(0));
|
|
444
554
|
};
|
|
445
555
|
process.once('SIGTERM', finishForSignal);
|
|
446
556
|
process.once('SIGHUP', finishForSignal);
|
|
@@ -448,7 +558,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
448
558
|
process.off('SIGTERM', finishForSignal);
|
|
449
559
|
process.off('SIGHUP', finishForSignal);
|
|
450
560
|
};
|
|
451
|
-
}, [player, store]);
|
|
561
|
+
}, [persistLibrary, player, reportActionError, store]);
|
|
452
562
|
const showStationContext = useCallback((context, next = 'stations', options = {}) => {
|
|
453
563
|
setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
|
|
454
564
|
go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
|
|
@@ -650,7 +760,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
650
760
|
});
|
|
651
761
|
selectedByScreenRef.current.search = 0;
|
|
652
762
|
lastSubmittedSearchRef.current = query.trim();
|
|
653
|
-
|
|
763
|
+
persistLibrary(() => store.addSearch(query));
|
|
654
764
|
searchHistoryRef.current = { cursor: -1, draft: '' };
|
|
655
765
|
setSelected(0);
|
|
656
766
|
setEditingSearch(true);
|
|
@@ -664,7 +774,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
664
774
|
if (requestId === searchRequestRef.current)
|
|
665
775
|
setLoadingStations(false);
|
|
666
776
|
}
|
|
667
|
-
}, [filters, providers, searchQuery, setStationContextFor, store]);
|
|
777
|
+
}, [filters, persistLibrary, providers, searchQuery, setStationContextFor, store]);
|
|
668
778
|
const loadMoreSearchResults = useCallback(async () => {
|
|
669
779
|
const context = stationContextsRef.current.search;
|
|
670
780
|
const query = context.query;
|
|
@@ -865,11 +975,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
865
975
|
await player.play(station, resolved.url);
|
|
866
976
|
if (requestId !== tuneRequestRef.current)
|
|
867
977
|
return;
|
|
978
|
+
playingStationRef.current = station;
|
|
868
979
|
setPlayingStation(station);
|
|
869
980
|
setTuningStation(null);
|
|
870
981
|
playbackQueueRef.current = queue;
|
|
871
|
-
const nextLibrary = store.addRecent(station);
|
|
872
|
-
if (screenRef.current === 'library') {
|
|
982
|
+
const nextLibrary = persistLibrary(() => store.addRecent(station));
|
|
983
|
+
if (nextLibrary && screenRef.current === 'library') {
|
|
873
984
|
const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
|
|
874
985
|
const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station));
|
|
875
986
|
if (nextLibraryIndex >= 0) {
|
|
@@ -877,7 +988,6 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
877
988
|
setSelected(nextLibraryIndex);
|
|
878
989
|
}
|
|
879
990
|
}
|
|
880
|
-
setLibrary(nextLibrary);
|
|
881
991
|
if (options.openNowPlaying) {
|
|
882
992
|
go('now-playing');
|
|
883
993
|
}
|
|
@@ -903,7 +1013,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
903
1013
|
setTuningStation(null);
|
|
904
1014
|
setMessage(message);
|
|
905
1015
|
}
|
|
906
|
-
}, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
1016
|
+
}, [filters, go, persistLibrary, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
907
1017
|
// Resume the most recent station on launch when opted in, like a radio
|
|
908
1018
|
// powering back on to its last frequency. Runs once and never auto-navigates.
|
|
909
1019
|
const didResumeRef = useRef(false);
|
|
@@ -929,7 +1039,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
929
1039
|
setTuningStation(null);
|
|
930
1040
|
setMessage('Auto-skip canceled. Choose a station and press Enter.');
|
|
931
1041
|
}, []);
|
|
932
|
-
const showTransientFooterMessage = useCallback((nextMessage, durationMs =
|
|
1042
|
+
const showTransientFooterMessage = useCallback((nextMessage, durationMs = TRANSIENT_NOTICE_MS) => {
|
|
933
1043
|
if (transientFooterMessageTimerRef.current) {
|
|
934
1044
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
935
1045
|
}
|
|
@@ -955,31 +1065,37 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
955
1065
|
}
|
|
956
1066
|
const wasFavorite = store.isFavorite(station);
|
|
957
1067
|
setLibrary(store.toggleFavorite(station));
|
|
1068
|
+
setPersistenceWarning(null);
|
|
958
1069
|
const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
|
|
959
|
-
if (screenRef.current === 'library') {
|
|
1070
|
+
if (screenRef.current === 'library' || screenRef.current === 'search') {
|
|
960
1071
|
showTransientFooterMessage(favoriteMessage);
|
|
961
1072
|
}
|
|
962
1073
|
else {
|
|
963
|
-
|
|
1074
|
+
showTransientMessage(favoriteMessage);
|
|
964
1075
|
}
|
|
965
1076
|
if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
|
|
966
1077
|
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
967
|
-
void providers.vote(station);
|
|
1078
|
+
void providers.vote(station).catch(reportActionError);
|
|
968
1079
|
}
|
|
969
|
-
}, [providers, showTransientFooterMessage, store]);
|
|
1080
|
+
}, [providers, reportActionError, showTransientFooterMessage, showTransientMessage, store]);
|
|
970
1081
|
const showControlResult = useCallback((result) => {
|
|
971
1082
|
if (!result.ok && result.message) {
|
|
972
1083
|
setMessage(result.message);
|
|
973
1084
|
}
|
|
974
1085
|
}, []);
|
|
975
|
-
const openStationHomepage = useCallback((station) => {
|
|
1086
|
+
const openStationHomepage = useCallback(async (station) => {
|
|
976
1087
|
if (!station?.homepage) {
|
|
977
1088
|
setMessage('This station has no homepage.');
|
|
978
1089
|
return;
|
|
979
1090
|
}
|
|
980
|
-
|
|
1091
|
+
const url = safeExternalHttpUrl(station.homepage);
|
|
1092
|
+
if (!url) {
|
|
1093
|
+
setMessage('This station has no valid HTTP(S) homepage.');
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
setMessage(await openExternal(url)
|
|
981
1097
|
? `Opening homepage: ${station.name}`
|
|
982
|
-
:
|
|
1098
|
+
: `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}`);
|
|
983
1099
|
}, []);
|
|
984
1100
|
const copyStationUrl = useCallback(async (station) => {
|
|
985
1101
|
if (!station) {
|
|
@@ -990,12 +1106,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
990
1106
|
if (!url) {
|
|
991
1107
|
url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
|
|
992
1108
|
}
|
|
993
|
-
|
|
994
|
-
|
|
1109
|
+
const safeUrl = url ? safeMediaTarget(url) : null;
|
|
1110
|
+
if (!safeUrl) {
|
|
1111
|
+
setMessage(`No safe stream URL available for ${station.name}.`);
|
|
995
1112
|
return;
|
|
996
1113
|
}
|
|
997
|
-
const copied = copyToClipboard(
|
|
998
|
-
setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${
|
|
1114
|
+
const copied = await copyToClipboard(safeUrl);
|
|
1115
|
+
setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`);
|
|
999
1116
|
}, [providers]);
|
|
1000
1117
|
const submitAirPlayCode = useCallback((code) => {
|
|
1001
1118
|
const result = player.submitAirPlayPasscode(code);
|
|
@@ -1016,8 +1133,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1016
1133
|
updateSettings({ volume: player.getState().volume });
|
|
1017
1134
|
}
|
|
1018
1135
|
showControlResult(result);
|
|
1019
|
-
});
|
|
1020
|
-
}, [player, showControlResult, updateSettings]);
|
|
1136
|
+
}).catch(reportActionError);
|
|
1137
|
+
}, [player, reportActionError, showControlResult, updateSettings]);
|
|
1021
1138
|
const adjustVolume = useCallback((delta) => {
|
|
1022
1139
|
const requestId = volumeRequestRef.current + 1;
|
|
1023
1140
|
volumeRequestRef.current = requestId;
|
|
@@ -1026,24 +1143,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1026
1143
|
updateSettings({ volume: player.getState().volume });
|
|
1027
1144
|
}
|
|
1028
1145
|
showControlResult(result);
|
|
1029
|
-
});
|
|
1030
|
-
}, [player, showControlResult, updateSettings]);
|
|
1146
|
+
}).catch(reportActionError);
|
|
1147
|
+
}, [player, reportActionError, showControlResult, updateSettings]);
|
|
1031
1148
|
const toggleMute = useCallback(() => {
|
|
1032
|
-
void player.toggleMute().then(showControlResult);
|
|
1033
|
-
}, [player, showControlResult]);
|
|
1149
|
+
void player.toggleMute().then(showControlResult).catch(reportActionError);
|
|
1150
|
+
}, [player, reportActionError, showControlResult]);
|
|
1034
1151
|
const togglePause = useCallback(() => {
|
|
1035
|
-
void player.togglePause().then(showControlResult);
|
|
1036
|
-
}, [player, showControlResult]);
|
|
1037
|
-
const showTransientMessage = useCallback((nextMessage) => {
|
|
1038
|
-
if (transientMessageTimerRef.current) {
|
|
1039
|
-
clearTimeout(transientMessageTimerRef.current);
|
|
1040
|
-
}
|
|
1041
|
-
setMessage(nextMessage);
|
|
1042
|
-
transientMessageTimerRef.current = setTimeout(() => {
|
|
1043
|
-
setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
1044
|
-
transientMessageTimerRef.current = null;
|
|
1045
|
-
}, VISUALIZER_MESSAGE_MS);
|
|
1046
|
-
}, []);
|
|
1152
|
+
void player.togglePause().then(showControlResult).catch(reportActionError);
|
|
1153
|
+
}, [player, reportActionError, showControlResult]);
|
|
1047
1154
|
const cycleDisplayColor = useCallback(() => {
|
|
1048
1155
|
const theme = nextTheme(settingsRef.current.theme);
|
|
1049
1156
|
updateSettings({ theme });
|
|
@@ -1082,6 +1189,15 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1082
1189
|
setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
|
|
1083
1190
|
}, [updateSettings]);
|
|
1084
1191
|
const refreshAirPlayTargets = useCallback(async (announce = true) => {
|
|
1192
|
+
if (!airPlaySupported) {
|
|
1193
|
+
if (announce)
|
|
1194
|
+
showTransientMessage(airPlayMacOSOnlyMessage);
|
|
1195
|
+
return [];
|
|
1196
|
+
}
|
|
1197
|
+
if (networkPolicy().offline) {
|
|
1198
|
+
setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.');
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1085
1201
|
try {
|
|
1086
1202
|
const devices = await player.refreshAirPlayDevices();
|
|
1087
1203
|
setAvailableAirPlayDevices(devices);
|
|
@@ -1104,13 +1220,17 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1104
1220
|
}
|
|
1105
1221
|
return [];
|
|
1106
1222
|
}
|
|
1107
|
-
}, [player]);
|
|
1223
|
+
}, [airPlaySupported, player, showTransientMessage]);
|
|
1108
1224
|
const openAirPlaySettings = useCallback(() => {
|
|
1225
|
+
if (!airPlaySupported) {
|
|
1226
|
+
showTransientMessage(airPlayMacOSOnlyMessage);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1109
1229
|
const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
|
|
1110
1230
|
selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
|
|
1111
1231
|
go('airplay-settings', { resetSelection: false });
|
|
1112
1232
|
void refreshAirPlayTargets(false);
|
|
1113
|
-
}, [availableAirPlayDevices, go, refreshAirPlayTargets]);
|
|
1233
|
+
}, [airPlaySupported, availableAirPlayDevices, go, refreshAirPlayTargets, showTransientMessage]);
|
|
1114
1234
|
const selectAirPlayDeviceAt = useCallback((index) => {
|
|
1115
1235
|
const device = availableAirPlayDevices[index];
|
|
1116
1236
|
if (!device) {
|
|
@@ -1184,6 +1304,59 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1184
1304
|
updateSettings({ [key]: next });
|
|
1185
1305
|
setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
|
|
1186
1306
|
}, [updateSettings]);
|
|
1307
|
+
const toggleAgentSetting = useCallback((key) => {
|
|
1308
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1309
|
+
const next = !agentControl[key];
|
|
1310
|
+
updateSettings({ agentControl: { ...agentControl, [key]: next } });
|
|
1311
|
+
setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${next ? 'on' : 'off'}.`);
|
|
1312
|
+
}, [updateSettings]);
|
|
1313
|
+
const setAgentIntegrationEnabled = useCallback(async () => {
|
|
1314
|
+
if (configuringMcpRef.current) {
|
|
1315
|
+
setMessage('Agent integration setup is already in progress.');
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
configuringMcpRef.current = true;
|
|
1319
|
+
const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
|
|
1320
|
+
setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
|
|
1321
|
+
try {
|
|
1322
|
+
const results = await mcpConfigurator(enabled, mcpRuntime(), null);
|
|
1323
|
+
const failed = results.filter(item => item.status === 'failed');
|
|
1324
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1325
|
+
updateSettings({ agentControl: { ...agentControl, enabled } });
|
|
1326
|
+
setMessage(integrationResultMessage(enabled, results, failed));
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
if (!enabled) {
|
|
1330
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1331
|
+
updateSettings({ agentControl: { ...agentControl, enabled: false } });
|
|
1332
|
+
}
|
|
1333
|
+
setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1334
|
+
}
|
|
1335
|
+
finally {
|
|
1336
|
+
configuringMcpRef.current = false;
|
|
1337
|
+
}
|
|
1338
|
+
}, [mcpConfigurator, updateSettings]);
|
|
1339
|
+
const repairMcpIntegrations = useCallback(async () => {
|
|
1340
|
+
if (configuringMcpRef.current) {
|
|
1341
|
+
setMessage('Agent integration setup is already in progress.');
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
configuringMcpRef.current = true;
|
|
1345
|
+
setMessage('Checking and repairing agent integrations...');
|
|
1346
|
+
try {
|
|
1347
|
+
const results = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1348
|
+
const failed = results.filter(item => item.status === 'failed');
|
|
1349
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1350
|
+
updateSettings({ agentControl: { ...agentControl, enabled: true } });
|
|
1351
|
+
setMessage(integrationResultMessage(true, results, failed));
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
setMessage(`MCP repair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1355
|
+
}
|
|
1356
|
+
finally {
|
|
1357
|
+
configuringMcpRef.current = false;
|
|
1358
|
+
}
|
|
1359
|
+
}, [mcpConfigurator, updateSettings]);
|
|
1187
1360
|
const cycleSleepTimer = useCallback(() => {
|
|
1188
1361
|
const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
|
|
1189
1362
|
const next = nextSleepTimerMinutes(currentMinutes);
|
|
@@ -1261,7 +1434,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1261
1434
|
}
|
|
1262
1435
|
}
|
|
1263
1436
|
const command = updateCommandForInstall();
|
|
1264
|
-
const copied = copyToClipboard(command.command);
|
|
1437
|
+
const copied = await copyToClipboard(command.command);
|
|
1265
1438
|
const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
|
|
1266
1439
|
const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
|
|
1267
1440
|
const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
|
|
@@ -1273,7 +1446,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1273
1446
|
return;
|
|
1274
1447
|
}
|
|
1275
1448
|
const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
|
|
1276
|
-
if (!currentUpdateCheck
|
|
1449
|
+
if (!updateAvailableForVersion(currentUpdateCheck, installedVersion)) {
|
|
1277
1450
|
setMessage('Checking for updates...');
|
|
1278
1451
|
const latest = await refreshUpdateCheck();
|
|
1279
1452
|
if (latest.error) {
|
|
@@ -1293,12 +1466,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1293
1466
|
const result = await installUpdate(command.command);
|
|
1294
1467
|
installingUpdateRef.current = false;
|
|
1295
1468
|
if (result.ok) {
|
|
1296
|
-
|
|
1469
|
+
const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
|
|
1470
|
+
if (agentControl.enabled) {
|
|
1471
|
+
try {
|
|
1472
|
+
const repaired = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1473
|
+
const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.';
|
|
1474
|
+
setMessage(`Update installed.${suffix} Restart RadioCLI and open agent clients.`);
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then restart RadioCLI and open agent clients.');
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
else {
|
|
1481
|
+
setMessage('Update installed. Restart RadioCLI to use the new version.');
|
|
1482
|
+
}
|
|
1297
1483
|
return;
|
|
1298
1484
|
}
|
|
1299
1485
|
const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
|
|
1300
1486
|
setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
|
|
1301
|
-
}, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
|
|
1487
|
+
}, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
|
|
1302
1488
|
const executeCommand = useCommandExecutor({
|
|
1303
1489
|
beginLearningTransportKey,
|
|
1304
1490
|
countries,
|
|
@@ -1306,6 +1492,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1306
1492
|
loadCountry,
|
|
1307
1493
|
openAirPlaySettings,
|
|
1308
1494
|
openLibrary,
|
|
1495
|
+
persistLibrary,
|
|
1309
1496
|
player,
|
|
1310
1497
|
playingStation,
|
|
1311
1498
|
providers,
|
|
@@ -1350,6 +1537,167 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1350
1537
|
void playStation(nextStation, { queue });
|
|
1351
1538
|
}
|
|
1352
1539
|
}, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
|
|
1540
|
+
agentHandlerRef.current = async (command) => {
|
|
1541
|
+
const sessionStatus = () => ({
|
|
1542
|
+
owner: 'tui',
|
|
1543
|
+
playback: player.getState(),
|
|
1544
|
+
station: playingStationRef.current,
|
|
1545
|
+
queue: playbackQueueRef.current?.stations ?? [],
|
|
1546
|
+
output: {
|
|
1547
|
+
preferredBackend: settingsRef.current.preferredBackend,
|
|
1548
|
+
preferredAirPlayDevice: settingsRef.current.preferredAirPlayDevice
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
const respond = (message, ok = true, data) => ({ ok, message, status: sessionStatus(), ...(data ? { data } : {}) });
|
|
1552
|
+
if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) {
|
|
1553
|
+
return respond(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined);
|
|
1554
|
+
}
|
|
1555
|
+
if (command.type === 'status')
|
|
1556
|
+
return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
|
|
1557
|
+
if (command.type === 'play') {
|
|
1558
|
+
if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state)) {
|
|
1559
|
+
return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
|
|
1560
|
+
}
|
|
1561
|
+
const queue = { title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station] };
|
|
1562
|
+
await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying });
|
|
1563
|
+
const state = player.getState();
|
|
1564
|
+
const playingRequestedStation = playingStationRef.current
|
|
1565
|
+
&& stationKey(playingStationRef.current) === stationKey(command.station)
|
|
1566
|
+
&& state.state === 'playing'
|
|
1567
|
+
&& state.ready;
|
|
1568
|
+
return playingRequestedStation
|
|
1569
|
+
? respond(`Playing ${command.station.name}.`)
|
|
1570
|
+
: respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
|
|
1571
|
+
}
|
|
1572
|
+
if (command.type === 'pause') {
|
|
1573
|
+
const control = await player.pause();
|
|
1574
|
+
return respond(control.message ?? 'Paused.', control.ok);
|
|
1575
|
+
}
|
|
1576
|
+
if (command.type === 'resume') {
|
|
1577
|
+
const control = await player.resume();
|
|
1578
|
+
return respond(control.message ?? 'Resumed.', control.ok);
|
|
1579
|
+
}
|
|
1580
|
+
if (command.type === 'stop') {
|
|
1581
|
+
tuneRequestRef.current += 1;
|
|
1582
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
1583
|
+
await player.stop();
|
|
1584
|
+
playingStationRef.current = null;
|
|
1585
|
+
setPlayingStation(null);
|
|
1586
|
+
setTuningStation(null);
|
|
1587
|
+
playbackQueueRef.current = null;
|
|
1588
|
+
return respond('Stopped RadioCLI.');
|
|
1589
|
+
}
|
|
1590
|
+
if (command.type === 'alarm-preempt') {
|
|
1591
|
+
tuneRequestRef.current += 1;
|
|
1592
|
+
persistLibrary(() => store.finishActiveListeningSession());
|
|
1593
|
+
await player.stop();
|
|
1594
|
+
playingStationRef.current = null;
|
|
1595
|
+
setPlayingStation(null);
|
|
1596
|
+
setTuningStation(null);
|
|
1597
|
+
playbackQueueRef.current = null;
|
|
1598
|
+
return respond('Interactive playback yielded to the alarm.');
|
|
1599
|
+
}
|
|
1600
|
+
if (command.type === 'set-volume') {
|
|
1601
|
+
const control = await player.setVolume(clampVolume(command.volume));
|
|
1602
|
+
if (control.ok)
|
|
1603
|
+
updateSettings({ volume: player.getState().volume });
|
|
1604
|
+
return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
|
|
1605
|
+
}
|
|
1606
|
+
if (command.type === 'set-muted') {
|
|
1607
|
+
const control = await player.setMuted(command.muted);
|
|
1608
|
+
return respond(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
|
|
1609
|
+
}
|
|
1610
|
+
if (command.type === 'set-favorite') {
|
|
1611
|
+
const station = command.station ?? playingStationRef.current;
|
|
1612
|
+
if (!station)
|
|
1613
|
+
return respond('No active station to favorite.', false);
|
|
1614
|
+
const current = store.isFavorite(station);
|
|
1615
|
+
if (current !== command.favorite)
|
|
1616
|
+
toggleFavorite(station);
|
|
1617
|
+
return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
|
|
1618
|
+
}
|
|
1619
|
+
if (command.type === 'airplay-list') {
|
|
1620
|
+
if (networkPolicy().offline)
|
|
1621
|
+
return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
|
|
1622
|
+
const devices = await refreshAirPlayTargets(false);
|
|
1623
|
+
return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
|
|
1624
|
+
}
|
|
1625
|
+
if (command.type === 'airplay-select') {
|
|
1626
|
+
if (networkPolicy().offline)
|
|
1627
|
+
return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false);
|
|
1628
|
+
const devices = await refreshAirPlayTargets(false);
|
|
1629
|
+
const device = devices.find(item => item.id === command.deviceId);
|
|
1630
|
+
if (!device)
|
|
1631
|
+
return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
|
|
1632
|
+
if (device.local) {
|
|
1633
|
+
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1634
|
+
if (!backend)
|
|
1635
|
+
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1636
|
+
updateSettings({ preferredBackend: backend });
|
|
1637
|
+
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1638
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1639
|
+
}
|
|
1640
|
+
return respond(`Audio output set to this device (${backend}).`);
|
|
1641
|
+
}
|
|
1642
|
+
if (!isAirPlayBackendAvailable(availableBackends))
|
|
1643
|
+
return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
|
|
1644
|
+
updateSettings({ preferredAirPlayDevice: device.id });
|
|
1645
|
+
updateSettings({ preferredBackend: 'airplay' });
|
|
1646
|
+
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1647
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1648
|
+
}
|
|
1649
|
+
return respond(`Audio output set to AirPlay receiver ${device.name}.`);
|
|
1650
|
+
}
|
|
1651
|
+
if (command.type === 'airplay-local') {
|
|
1652
|
+
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1653
|
+
if (!backend)
|
|
1654
|
+
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1655
|
+
updateSettings({ preferredBackend: backend });
|
|
1656
|
+
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1657
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1658
|
+
}
|
|
1659
|
+
return respond(`Audio output set to this device (${backend}).`);
|
|
1660
|
+
}
|
|
1661
|
+
if (command.type === 'airplay-passcode') {
|
|
1662
|
+
const control = player.submitAirPlayPasscode(command.code);
|
|
1663
|
+
return respond(control.message ?? 'AirPlay code sent.', control.ok);
|
|
1664
|
+
}
|
|
1665
|
+
if (command.type === 'update-settings') {
|
|
1666
|
+
updateSettings(command.settings);
|
|
1667
|
+
return respond('RadioCLI settings updated.');
|
|
1668
|
+
}
|
|
1669
|
+
const active = playingStationRef.current;
|
|
1670
|
+
const queue = playbackQueueRef.current;
|
|
1671
|
+
if (!active || !queue?.stations.length)
|
|
1672
|
+
return respond('No playback queue is available.', false);
|
|
1673
|
+
const currentIndex = queue.stations.findIndex(item => stationKey(item) === stationKey(active));
|
|
1674
|
+
const delta = command.type === 'next' ? 1 : -1;
|
|
1675
|
+
const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
|
|
1676
|
+
if (!next)
|
|
1677
|
+
return respond('No adjacent station is available.', false);
|
|
1678
|
+
await playStation(next, { queue });
|
|
1679
|
+
return respond(`Playing ${next.name}.`);
|
|
1680
|
+
};
|
|
1681
|
+
useEffect(() => {
|
|
1682
|
+
if (!stdin.isTTY || !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
|
|
1683
|
+
return;
|
|
1684
|
+
let disposed = false;
|
|
1685
|
+
let close;
|
|
1686
|
+
void startRadioSession(command => agentHandlerRef.current(command)).then(async (session) => {
|
|
1687
|
+
if (disposed) {
|
|
1688
|
+
await session.close();
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
close = session.close;
|
|
1692
|
+
if (initialAgentCommand)
|
|
1693
|
+
await agentHandlerRef.current(initialAgentCommand);
|
|
1694
|
+
}).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
|
|
1695
|
+
return () => {
|
|
1696
|
+
disposed = true;
|
|
1697
|
+
if (close)
|
|
1698
|
+
void close();
|
|
1699
|
+
};
|
|
1700
|
+
}, [initialAgentCommand, library.settings.agentControl?.enabled, stdin]);
|
|
1353
1701
|
useAppInput({
|
|
1354
1702
|
adjustVolume,
|
|
1355
1703
|
airPlayCode,
|
|
@@ -1378,6 +1726,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1378
1726
|
openAdjacentTab,
|
|
1379
1727
|
openAirPlayCode,
|
|
1380
1728
|
openAirPlaySettings,
|
|
1729
|
+
openSettingsPage,
|
|
1730
|
+
closeSettingsPage,
|
|
1381
1731
|
openScreen,
|
|
1382
1732
|
handleAlarmInput: alarmTui.handleInput,
|
|
1383
1733
|
openAlarmForStation: alarmTui.openForStation,
|
|
@@ -1394,9 +1744,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1394
1744
|
void refreshAirPlayTargets();
|
|
1395
1745
|
},
|
|
1396
1746
|
resetLearnedTransportKeys,
|
|
1747
|
+
repairMcpIntegrations,
|
|
1748
|
+
setAgentIntegrationEnabled,
|
|
1397
1749
|
runSearch,
|
|
1398
1750
|
saveLearnedTransportKey,
|
|
1399
1751
|
screen,
|
|
1752
|
+
settingsPage,
|
|
1400
1753
|
searchQuery,
|
|
1401
1754
|
selectedRef,
|
|
1402
1755
|
selectedStationForInput,
|
|
@@ -1420,6 +1773,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1420
1773
|
toggleMute,
|
|
1421
1774
|
togglePause,
|
|
1422
1775
|
toggleSetting,
|
|
1776
|
+
toggleAgentSetting,
|
|
1423
1777
|
toggleNearbyLocation,
|
|
1424
1778
|
toggleDirectoryVoting,
|
|
1425
1779
|
toggleRadioGarden,
|
|
@@ -1442,6 +1796,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1442
1796
|
spinnerFrame
|
|
1443
1797
|
});
|
|
1444
1798
|
const basePageFooter = pageFooterText({
|
|
1799
|
+
airPlaySupported,
|
|
1445
1800
|
canEnterAirPlayCode,
|
|
1446
1801
|
capturingTransportAction,
|
|
1447
1802
|
commandMode,
|
|
@@ -1449,7 +1804,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1449
1804
|
editingCountryFilter,
|
|
1450
1805
|
editingSearch,
|
|
1451
1806
|
playbackBackend: playback.backend,
|
|
1452
|
-
screen
|
|
1807
|
+
screen,
|
|
1808
|
+
settingsPage
|
|
1453
1809
|
});
|
|
1454
1810
|
const pageFooter = alarmTui.activeAlarms.length > 0 && screen !== 'alarm-ringing'
|
|
1455
1811
|
? `ALARM PLAYING · ! controls · ${basePageFooter}`
|
|
@@ -1460,7 +1816,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1460
1816
|
(screen === 'search' && editingSearch) ||
|
|
1461
1817
|
screen === 'airplay-code');
|
|
1462
1818
|
const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
|
|
1463
|
-
const
|
|
1819
|
+
const statusMessage = message ?? persistenceWarning ?? presenceWarning;
|
|
1820
|
+
const microFooter = footerMessage ?? statusMessage ?? (pageFooterOwnsCompactRow
|
|
1464
1821
|
? pageFooter
|
|
1465
1822
|
: footerPlayback.state === 'loading' && playbackFooter
|
|
1466
1823
|
? playbackFooter
|
|
@@ -1468,19 +1825,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1468
1825
|
? microPlaybackControlsText(playback.backend)
|
|
1469
1826
|
: pageFooter);
|
|
1470
1827
|
const compactGlobalFooter = '←/→ tabs · ? help · q quit';
|
|
1471
|
-
const
|
|
1472
|
-
const
|
|
1473
|
-
const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
|
|
1828
|
+
const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
|
|
1829
|
+
const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter);
|
|
1474
1830
|
const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
|
|
1475
|
-
const compactStatus = footerMessage ??
|
|
1831
|
+
const compactStatus = footerMessage ?? statusMessage ?? playbackFooter;
|
|
1476
1832
|
const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
|
|
1477
1833
|
const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
|
|
1478
1834
|
const microLegendWidth = Math.max(1, frameWidth - versionReserve);
|
|
1479
|
-
const microLegend = commandMode || capturingTransportAction || footerMessage ||
|
|
1835
|
+
const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage
|
|
1480
1836
|
? microFooter
|
|
1481
1837
|
: microShortcutFooterText(microFooter, microLegendWidth);
|
|
1482
1838
|
const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
|
|
1483
|
-
return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion:
|
|
1839
|
+
return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: displayMode.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, airPlaySupported: airPlaySupported, 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 || statusMessage ? 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 || statusMessage ? 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}`))] }) })) })] }) }) }));
|
|
1840
|
+
}
|
|
1841
|
+
function mcpRuntime() {
|
|
1842
|
+
return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
|
|
1843
|
+
}
|
|
1844
|
+
function integrationResultMessage(enabled, results, failed) {
|
|
1845
|
+
if (failed.length > 0) {
|
|
1846
|
+
return `${enabled ? 'Agent control enabled' : 'Agent control disabled'}, but ${failed.map(item => item.client).join(', ')} need attention. Run radiocli mcp status for details.`;
|
|
1847
|
+
}
|
|
1848
|
+
const configured = results.filter(item => item.status === (enabled ? 'configured' : 'removed')).map(item => item.client);
|
|
1849
|
+
if (!enabled)
|
|
1850
|
+
return 'Agent and voice control disabled; detected MCP registrations were removed.';
|
|
1851
|
+
return `Agent and voice control ready${configured.length ? ` for ${configured.join(', ')}` : ''}. Restart open Codex or agent clients, then ask them to control RadioCLI.`;
|
|
1484
1852
|
}
|
|
1485
1853
|
function buildLibraryStations(library) {
|
|
1486
1854
|
const stations = [];
|
|
@@ -1496,12 +1864,12 @@ function buildLibraryStations(library) {
|
|
|
1496
1864
|
for (const station of library.favorites) {
|
|
1497
1865
|
addStation(station);
|
|
1498
1866
|
}
|
|
1499
|
-
for (const item of library.recent) {
|
|
1500
|
-
addStation(item.station);
|
|
1501
|
-
}
|
|
1502
1867
|
for (const station of library.imported) {
|
|
1503
1868
|
addStation(station);
|
|
1504
1869
|
}
|
|
1870
|
+
for (const item of library.recent) {
|
|
1871
|
+
addStation(item.station);
|
|
1872
|
+
}
|
|
1505
1873
|
return stations;
|
|
1506
1874
|
}
|
|
1507
1875
|
function exploreCursorLocation(cursor) {
|
|
@@ -1599,7 +1967,7 @@ function audioOutputSwitchLabel(output, backends) {
|
|
|
1599
1967
|
return audioOutputLabel(output);
|
|
1600
1968
|
}
|
|
1601
1969
|
function librarySubtitle(library) {
|
|
1602
|
-
return `${library.favorites.length} favorites · ${library.
|
|
1970
|
+
return `${library.favorites.length} favorites · ${library.imported.length} imported · ${library.recent.length} recent · new imports before recents`;
|
|
1603
1971
|
}
|
|
1604
1972
|
function mouseVisibleRows(screen, layout) {
|
|
1605
1973
|
if (screen === 'help')
|