@ciphore/radiocli 0.2.3 → 0.2.5

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.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/ui/App.js CHANGED
@@ -1,11 +1,14 @@
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
3
  import { fileURLToPath } from 'node:url';
4
- import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
4
+ import { Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize } from 'ink';
5
5
  import { ProviderManager } from '../providers/provider-manager.js';
6
- import { PlayerController } from '../player/player-controller.js';
7
- import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
6
+ import { PlaybackOutputError, PlayerController } from '../player/player-controller.js';
7
+ import { airPlayLocalDeviceLabel, playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
8
8
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
9
+ import { LibraryRepository } from '../storage/repository.js';
10
+ import { RadioEngine } from '../player/radio-engine.js';
11
+ import { TuiPlayer, TuiRadioEngine } from '../agent/tui-session.js';
9
12
  import { defaultAgentControlSettings } from '../types.js';
10
13
  import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
11
14
  import { DisplayContext, resolveDisplayMode } from './display-context.js';
@@ -21,29 +24,31 @@ import { useAppInput } from './use-app-input.js';
21
24
  import { useCommandExecutor } from './use-command-executor.js';
22
25
  import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
23
26
  import { isAirPlayBackendAvailable } from './airplay-settings.js';
27
+ import { networkPolicy } from '../platform/network.js';
24
28
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
25
29
  import { copyToClipboard, openExternal } from './system-actions.js';
30
+ import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
26
31
  import { appVersion } from '../version.js';
27
32
  import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
28
- import { helpItemCount } from './help-content.js';
33
+ import { airPlayPolicy, airPlaySetupRequiredMessage } from '../player/airplay-policy.js';
34
+ import { helpItemCountForAirPlay } from './help-content.js';
29
35
  import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
30
36
  import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
31
37
  import { useAlarmTui } from './use-alarm-tui.js';
32
38
  import { toAsciiSafe } from './ascii.js';
33
39
  import { registerTuiPresence } from '../alarms/tui-presence.js';
34
40
  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';
41
+ import { allowedRadioSessionSettings, resolveDesktopTransport, serializeRadioCommands, startRadioSession } from '../agent/session.js';
42
+ import { desktopMediaPolicy, startDesktopMediaControls } from '../platform/desktop-media.js';
43
+ import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
37
44
  import { ReceiverAnimationProvider } from './receiver-animation.js';
38
45
  import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
39
46
  const LOADING_SPINNER_MS = 120;
40
- const VISUALIZER_MESSAGE_MS = 4500;
41
- const LISTENING_HEARTBEAT_MS = 30_000;
47
+ const TRANSIENT_NOTICE_MS = 4500;
42
48
  const COUNTRY_STATIONS_PAGE_SIZE = 120;
43
49
  const COUNTRY_STATIONS_LOAD_AHEAD = 12;
44
50
  const SEARCH_RESULTS_PAGE_SIZE = 90;
45
51
  const SEARCH_RESULTS_LOAD_AHEAD = 12;
46
- const BROKEN_STREAM_AUTO_SKIP_DELAY_MS = 1000;
47
52
  const settingToggleLabel = {
48
53
  resumeOnLaunch: 'Resume on launch',
49
54
  transparentBackground: 'Transparent background',
@@ -52,33 +57,40 @@ const settingToggleLabel = {
52
57
  mouseSupport: 'Mouse and trackpad scrolling',
53
58
  automaticUpdateChecks: 'Automatic update checks'
54
59
  };
55
- export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate }) {
60
+ export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform, playbackSession, playbackRecoveryWarning }) {
56
61
  const { exit } = useApp();
57
62
  const { stdin } = useStdin();
58
63
  const { stdout } = useStdout();
59
64
  const { columns, rows } = useWindowSize();
60
- const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
65
+ const screenReader = useIsScreenReaderEnabled();
66
+ const store = useMemo(() => new LibraryRepository(providedStore ?? new JsonLibraryStore(), { worker: !providedStore }), [providedStore]);
61
67
  const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
62
68
  const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
63
69
  const installedVersion = useMemo(() => appVersion(), []);
64
- const [library, setLibrary] = useState(() => store.snapshot());
70
+ const [library, setLibrary] = useState(() => localSessionLibrary(store.snapshot(), platform, playbackSession?.status.tui?.selectionRequired));
65
71
  const settingsRef = useRef(library.settings);
66
- settingsRef.current = library.settings;
67
- const player = useMemo(() => new PlayerController(() => settingsRef.current), []);
72
+ const settingsQueueRef = useRef(Promise.resolve());
73
+ const player = useMemo(() => playbackSession ? new TuiPlayer(playbackSession, () => settingsRef.current)
74
+ : new PlayerController(() => settingsRef.current, { platform, arch: process.arch, env: platform === process.platform ? process.env : {} }), [platform, playbackSession]);
68
75
  const [playback, setPlayback] = useState(() => player.getState());
69
76
  const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends());
77
+ const airPlay = airPlayPolicy(library.settings, { platform });
78
+ const airPlaySupported = airPlay.enabled;
79
+ const airPlayUnavailableMessage = airPlay.enabled ? (platform === 'linux' ? airPlaySetupRequiredMessage : 'AirPlay is not ready on this install. Run radiocli doctor.') : airPlay.message;
70
80
  const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
71
- const [screen, setScreen] = useState('home');
81
+ const [screen, setScreen] = useState(playbackSession?.status.station ? 'now-playing' : 'home');
72
82
  const [selected, setSelected] = useState(0);
73
83
  const [settingsPage, setSettingsPage] = useState('root');
74
84
  const [message, setMessage] = useState(null);
85
+ const [persistenceWarning, setPersistenceWarning] = useState(null);
86
+ const [presenceWarning, setPresenceWarning] = useState(playbackRecoveryWarning ?? null);
75
87
  const [footerMessage, setFooterMessage] = useState(null);
76
88
  const [countries, setCountries] = useState([]);
77
89
  const [countryFilter, setCountryFilter] = useState('');
78
90
  const [editingCountryFilter, setEditingCountryFilter] = useState(false);
79
91
  const [loadingCountries, setLoadingCountries] = useState(false);
80
92
  const [stationContexts, setStationContexts] = useState(initialStationContexts);
81
- const [loadingStations, setLoadingStations] = useState(false);
93
+ const [loadingStationContexts, setLoadingStationContexts] = useState({});
82
94
  const [searchQuery, setSearchQuery] = useState('');
83
95
  const [editingSearch, setEditingSearch] = useState(true);
84
96
  const [playingStation, setPlayingStation] = useState(null);
@@ -92,20 +104,50 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
92
104
  const [commandText, setCommandText] = useState('');
93
105
  const [airPlayCode, setAirPlayCode] = useState('');
94
106
  const [filters, setFilters] = useState({ codec: null, language: null, minBitrate: null });
95
- const [sleepUntil, setSleepUntil] = useState(null);
107
+ const [sleepUntil, setSleepUntilState] = useState(playbackSession?.status.tui?.sleepUntil ?? null);
108
+ const setSleepUntil = useCallback((until) => {
109
+ if (playbackSession)
110
+ void playbackSession.call({ type: 'tui-sleep', until }).catch(error => setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not update the sleep timer.'));
111
+ else
112
+ setSleepUntilState(until);
113
+ }, [playbackSession]);
96
114
  const [showDiagnostics, setShowDiagnostics] = useState(false);
97
115
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
116
+ const reportActionError = useCallback((error) => {
117
+ setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
118
+ }, []);
119
+ // Optional history cannot change the result of playback or navigation. Keep
120
+ // its warning until an explicit save succeeds; a no-op checkpoint proves nothing.
121
+ const persistLibrary = useCallback(async (write) => {
122
+ try {
123
+ const nextLibrary = localSessionLibrary(await write(), platform, player.airPlaySelectionRequired());
124
+ setLibrary(nextLibrary);
125
+ return nextLibrary;
126
+ }
127
+ catch (error) {
128
+ const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error));
129
+ setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`);
130
+ return undefined;
131
+ }
132
+ }, [platform, player]);
133
+ const engine = useMemo(() => playbackSession ? new TuiRadioEngine(playbackSession) : new RadioEngine(player, providers, store, () => settingsRef.current, saved => {
134
+ setLibrary(localSessionLibrary(saved, platform, player.airPlaySelectionRequired()));
135
+ }), [player, providers, store, platform, playbackSession]);
98
136
  const announcedUpdateRef = useRef(false);
99
137
  const automaticUpdateCheckStartedRef = useRef(false);
100
138
  const installingUpdateRef = useRef(false);
101
139
  const configuringMcpRef = useRef(false);
102
140
  const displayStationsRef = useRef([]);
103
- const playbackQueueRef = useRef(null);
141
+ const playbackQueueRef = useRef(playbackSession?.status.queue.length
142
+ ? { title: 'Recovered station list', sourceScreen: 'now-playing', sourceContextKey: null, stations: playbackSession.status.queue } : null);
143
+ const focusNowPlayingRef = useRef(playbackSession?.status.tui?.focusNowPlaying ?? 0);
104
144
  const lastRawTransportAtRef = useRef(0);
105
- const loadingStationsRef = useRef(false);
145
+ const loadingStationContextsRef = useRef({});
106
146
  const countryPageRequestRef = useRef(null);
107
147
  const searchPageRequestRef = useRef(null);
108
- const playStationRef = useRef(() => undefined);
148
+ const activeSearchRef = useRef(null);
149
+ const searchContextFingerprintRef = useRef(null);
150
+ const searchFingerprint = JSON.stringify([filters.codec, filters.language, filters.minBitrate, Boolean(library.settings.enableRadioGarden)]);
109
151
  const screenRef = useRef(screen);
110
152
  const selectedRef = useRef(selected);
111
153
  const settingsPageRef = useRef(settingsPage);
@@ -124,16 +166,45 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
124
166
  const helpReturnScreenRef = useRef('home');
125
167
  const countriesLoadAttemptedRef = useRef(false);
126
168
  const tuneRequestRef = useRef(0);
169
+ const outputCancellationRef = useRef(0);
170
+ const pendingOutputChangesRef = useRef(0);
127
171
  const searchRequestRef = useRef(0);
128
172
  const countryRequestRef = useRef(0);
129
173
  const nearbyRequestRef = useRef(0);
130
- const skipBrokenTimerRef = useRef(null);
174
+ const discoveryAbortRef = useRef({});
175
+ const beginDiscovery = useCallback((key) => {
176
+ discoveryAbortRef.current[key]?.abort();
177
+ const controller = new AbortController();
178
+ discoveryAbortRef.current[key] = controller;
179
+ return controller.signal;
180
+ }, []);
181
+ const cancelPendingTune = useCallback(() => {
182
+ tuneRequestRef.current += 1;
183
+ outputCancellationRef.current += 1;
184
+ engine.cancel();
185
+ }, [engine]);
131
186
  const volumeRequestRef = useRef(0);
132
187
  const agentHandlerRef = useRef(async () => {
133
188
  throw new Error('RadioCLI is still starting.');
134
189
  });
190
+ const dispatchOwnerCommand = useMemo(() => serializeRadioCommands((command, signal) => agentHandlerRef.current(command, signal), { responsive: true }), []);
191
+ const desktopPolicy = useMemo(() => desktopMediaPolicy(library.settings, { platform }), [library.settings.desktopMediaControls, platform]);
192
+ const desktopSuspendedRef = useRef(false);
193
+ const desktopMediaRef = useRef(null);
194
+ const activeAlarmRef = useRef(false);
195
+ const [controlSessionReady, setControlSessionReady] = useState(Boolean(playbackSession));
196
+ const showTransientMessage = useCallback((nextMessage) => {
197
+ if (transientMessageTimerRef.current) {
198
+ clearTimeout(transientMessageTimerRef.current);
199
+ }
200
+ setMessage(nextMessage);
201
+ transientMessageTimerRef.current = setTimeout(() => {
202
+ setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
203
+ transientMessageTimerRef.current = null;
204
+ }, TRANSIENT_NOTICE_MS);
205
+ }, []);
135
206
  const theme = library.settings.theme;
136
- const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
207
+ 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]);
137
208
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
138
209
  const diagnostics = player.diagnostics();
139
210
  const filterLabel = formatFilterLabel(filters);
@@ -156,10 +227,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
156
227
  screenRef.current = screen;
157
228
  selectedRef.current = selected;
158
229
  settingsPageRef.current = settingsPage;
159
- loadingStationsRef.current = loadingStations;
160
230
  stationContextsRef.current = activeStationContexts;
161
231
  exploreCursorRef.current = exploreCursor;
162
232
  const renderedStationContextKey = stationContextKeyForScreen(screen);
233
+ const loadingStations = renderedStationContextKey ? Boolean(loadingStationContexts[renderedStationContextKey]) : false;
163
234
  const activeStationContextKey = renderedStationContextKey ?? lastStationContextKeyRef.current;
164
235
  const stationContext = activeStationContexts[activeStationContextKey];
165
236
  const stationCounts = useMemo(() => ({
@@ -186,8 +257,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
186
257
  'alarm-ringing': 0,
187
258
  'airplay-settings': 0,
188
259
  'airplay-code': 1,
189
- settings: settingsItemsForPage(settingsPage).length,
190
- help: helpItemCount
260
+ settings: settingsItemsForPage(settingsPage, airPlaySupported).length,
261
+ help: helpItemCountForAirPlay(airPlaySupported)
191
262
  });
192
263
  itemCountsRef.current = {
193
264
  home: homeItems.length,
@@ -206,8 +277,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
206
277
  'alarm-ringing': 0,
207
278
  'airplay-settings': availableAirPlayDevices.length,
208
279
  'airplay-code': 1,
209
- settings: settingsItemsForPage(settingsPage).length,
210
- help: helpItemCount
280
+ settings: settingsItemsForPage(settingsPage, airPlaySupported).length,
281
+ help: helpItemCountForAirPlay(airPlaySupported)
211
282
  };
212
283
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
213
284
  displayStationsRef.current = displayStations;
@@ -222,52 +293,69 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
222
293
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
223
294
  const layout = computeTerminalLayout(columns, rows, footerRows);
224
295
  const frameWidth = layout.frameWidth;
225
- const mouseReportingActive = !commandMode &&
296
+ const mouseReportingActive = !displayMode.screenReader &&
297
+ process.env.TERM?.toLowerCase() !== 'dumb' &&
298
+ !commandMode &&
226
299
  !capturingTransportAction &&
227
300
  !editingCountryFilter &&
228
301
  shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
229
- useEffect(() => player.onChange(setPlayback), [player]);
230
- useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]);
231
- const playingStationRef = useRef(null);
232
- playingStationRef.current = playingStation;
233
- const activeListeningStationRef = useRef(null);
234
- const lastRecordedTrackRef = useRef(null);
235
302
  useEffect(() => {
236
- const station = playingStationRef.current;
237
- const isAudible = playback.state === 'playing' && playback.ready && station;
238
- if (isAudible) {
239
- const key = stationKey(station);
240
- if (activeListeningStationRef.current !== key) {
241
- activeListeningStationRef.current = key;
242
- setLibrary(store.startListeningSession(station));
243
- }
303
+ if (!stdin.isTTY)
244
304
  return;
305
+ try {
306
+ const unregister = registerTuiPresence();
307
+ return () => {
308
+ try {
309
+ unregister();
310
+ }
311
+ catch {
312
+ console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');
313
+ }
314
+ };
245
315
  }
246
- if (activeListeningStationRef.current) {
247
- activeListeningStationRef.current = null;
248
- setLibrary(store.finishActiveListeningSession());
249
- }
250
- }, [playback.ready, playback.state, playingStation, store]);
251
- useEffect(() => {
252
- if (playback.state !== 'playing' || !playback.ready || !playingStation) {
253
- return;
316
+ catch {
317
+ setPresenceWarning([playbackRecoveryWarning, 'Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'].filter(Boolean).join(' '));
254
318
  }
255
- const timer = setInterval(() => {
256
- setLibrary(store.checkpointActiveListeningSession());
257
- }, LISTENING_HEARTBEAT_MS);
258
- return () => clearInterval(timer);
259
- }, [playback.ready, playback.state, playingStation, store]);
260
- useEffect(() => player.onMetadata(metadata => {
261
- setNowPlaying(metadata);
262
- const station = playingStationRef.current;
263
- if (station && metadata.title) {
264
- const trackKey = `${stationKey(station)}:${metadata.title}`;
265
- if (lastRecordedTrackRef.current === trackKey)
266
- return;
267
- lastRecordedTrackRef.current = trackKey;
268
- setLibrary(store.recordTrack(station, metadata.title));
319
+ }, [stdin, playbackRecoveryWarning]);
320
+ const playingStationRef = useRef(null);
321
+ playingStationRef.current = playingStation;
322
+ useEffect(() => engine.onChange(current => {
323
+ setPlayback(current.playback);
324
+ playingStationRef.current = current.station;
325
+ setPlayingStation(current.station);
326
+ setTuningStation(current.tuningStation);
327
+ setNowPlaying(current.nowPlaying);
328
+ if (playbackSession) {
329
+ setSleepUntilState(playbackSession.status.tui?.sleepUntil ?? null);
330
+ const previous = playbackQueueRef.current;
331
+ playbackQueueRef.current = current.queue.length ? { ...(previous ?? { title: 'Current station list', sourceScreen: 'now-playing', sourceContextKey: null }), stations: current.queue } : null;
332
+ const focus = playbackSession.status.tui?.focusNowPlaying ?? 0;
333
+ if (focus !== focusNowPlayingRef.current) {
334
+ focusNowPlayingRef.current = focus;
335
+ screenRef.current = 'now-playing';
336
+ setScreen('now-playing');
337
+ }
269
338
  }
270
- }), [player, store]);
339
+ if (current.warning)
340
+ setPersistenceWarning(current.warning.replace('Listening history was not saved', 'Library not saved'));
341
+ if (current.message)
342
+ setMessage(current.message);
343
+ }), [engine, playbackSession]);
344
+ useEffect(() => {
345
+ const off = store.subscribe(saved => {
346
+ const next = localSessionLibrary(saved, platform, player.airPlaySelectionRequired());
347
+ settingsRef.current = next.settings;
348
+ setLibrary(next);
349
+ });
350
+ const offError = store.onError(error => setPersistenceWarning(`Library could not refresh: ${error.message}`));
351
+ return () => {
352
+ off();
353
+ offError();
354
+ // Normal shutdown closes the owner explicitly. An unexpected render
355
+ // failure must release this view's storage without stopping its audio.
356
+ void (playbackSession ? store.close() : engine.close().finally(() => store.close())).catch(() => undefined);
357
+ };
358
+ }, [store, engine, platform, player, playbackSession]);
271
359
  useEffect(() => {
272
360
  if (!stdout.isTTY || !mouseReportingActive) {
273
361
  return;
@@ -285,7 +373,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
285
373
  }, [renderedStationContextKey, screen, selected]);
286
374
  useEffect(() => {
287
375
  if (footerPlayback.state !== 'loading' ||
288
- library.settings.reduceMotion ||
376
+ displayMode.reduceMotion ||
289
377
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
290
378
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
291
379
  setSpinnerFrame(0);
@@ -293,7 +381,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
293
381
  }
294
382
  const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
295
383
  return () => clearInterval(timer);
296
- }, [footerPlayback.state, library.settings.reduceMotion]);
384
+ }, [footerPlayback.state, displayMode.reduceMotion]);
297
385
  useEffect(() => {
298
386
  if ((screen === 'countries' || screen === 'map') &&
299
387
  countries.length === 0 &&
@@ -309,6 +397,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
309
397
  }
310
398
  }, [countries.length, loadingCountries, providers, screen]);
311
399
  useEffect(() => () => {
400
+ exploreRequestRef.current += 1;
401
+ countryRequestRef.current += 1;
402
+ searchRequestRef.current += 1;
403
+ nearbyRequestRef.current += 1;
404
+ for (const controller of Object.values(discoveryAbortRef.current))
405
+ controller.abort();
406
+ countryPageRequestRef.current = null;
407
+ searchPageRequestRef.current = null;
312
408
  if (exploreMoveTimerRef.current) {
313
409
  clearTimeout(exploreMoveTimerRef.current);
314
410
  }
@@ -318,41 +414,41 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
318
414
  if (transientFooterMessageTimerRef.current) {
319
415
  clearTimeout(transientFooterMessageTimerRef.current);
320
416
  }
321
- if (skipBrokenTimerRef.current) {
322
- clearTimeout(skipBrokenTimerRef.current);
323
- }
324
417
  }, []);
325
418
  useEffect(() => {
326
419
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
327
420
  }, [availableAirPlayDevices.length, displayStations.length, filteredCountries.length, screen]);
328
421
  useEffect(() => {
422
+ if (playbackSession)
423
+ return;
329
424
  if (!sleepUntil) {
330
425
  return;
331
426
  }
332
427
  const delayMs = sleepUntil - Date.now();
333
428
  if (delayMs <= 0) {
334
- tuneRequestRef.current += 1;
335
- setLibrary(store.finishActiveListeningSession());
336
- void player.stop();
429
+ cancelPendingTune();
430
+ void engine.stop({ retainSelection: true }).catch(reportActionError);
337
431
  setSleepUntil(null);
338
432
  return;
339
433
  }
340
434
  const timer = setTimeout(() => {
341
- tuneRequestRef.current += 1;
342
- setLibrary(store.finishActiveListeningSession());
343
- void player.stop();
435
+ cancelPendingTune();
436
+ void engine.stop({ retainSelection: true }).catch(reportActionError);
344
437
  setSleepUntil(null);
345
438
  }, delayMs);
346
439
  return () => clearTimeout(timer);
347
- }, [player, sleepUntil, store]);
440
+ }, [cancelPendingTune, engine, reportActionError, sleepUntil, playbackSession, setSleepUntil]);
348
441
  useEffect(() => {
349
442
  const backends = player.refreshDetectedBackends();
350
443
  setAvailableBackends(backends);
351
- void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
444
+ const network = networkPolicy();
445
+ if (platform === 'darwin' && airPlaySupported && !network.offline && !network.lowBandwidth) {
446
+ void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
447
+ }
352
448
  if (backends.length === 0) {
353
449
  setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
354
450
  }
355
- }, [player]);
451
+ }, [airPlaySupported, platform, player]);
356
452
  const refreshProviderHealth = useCallback(() => {
357
453
  providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
358
454
  }, [providers]);
@@ -361,9 +457,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
361
457
  }, [refreshProviderHealth]);
362
458
  const refreshUpdateCheck = useCallback(async () => {
363
459
  const updateCheck = await updateChecker({ currentVersion: installedVersion });
364
- setLibrary(store.updateCheckState(updateCheck));
460
+ persistLibrary(() => store.updateCheckState(updateCheck));
365
461
  return updateCheck;
366
- }, [installedVersion, store, updateChecker]);
462
+ }, [installedVersion, persistLibrary, store, updateChecker]);
367
463
  useEffect(() => {
368
464
  if (automaticUpdateCheckStartedRef.current ||
369
465
  !automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
@@ -379,51 +475,93 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
379
475
  announcedUpdateRef.current = true;
380
476
  setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
381
477
  }
478
+ }).catch(error => {
479
+ if (!cancelled)
480
+ reportActionError(error);
382
481
  });
383
482
  return () => {
384
483
  cancelled = true;
385
484
  };
386
- }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]);
485
+ }, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]);
387
486
  const setStationContextFor = useCallback((key, context) => {
487
+ stationContextsRef.current = { ...stationContextsRef.current, [key]: context };
388
488
  setStationContexts(current => ({ ...current, [key]: context }));
389
489
  }, []);
490
+ const setStationLoading = useCallback((key, loading) => {
491
+ if (loadingStationContextsRef.current[key] === loading)
492
+ return;
493
+ loadingStationContextsRef.current[key] = loading;
494
+ setLoadingStationContexts(current => ({ ...current, [key]: loading }));
495
+ }, []);
390
496
  const stationMatches = useCallback((left, right) => stationKey(left) === stationKey(right), []);
391
497
  const queueContainsStation = useCallback((queue, station) => Boolean(queue?.stations.some(item => stationMatches(item, station))), [stationMatches]);
392
- const updateSettings = useCallback((settings) => {
393
- const nextLibrary = store.updateSettings(settings);
394
- settingsRef.current = nextLibrary.settings;
395
- setLibrary(nextLibrary);
396
- return nextLibrary;
397
- }, [store]);
498
+ const updateSettings = useCallback((update) => {
499
+ // Evaluate relative inputs after the preceding save has acknowledged. React
500
+ // may still be rendering that save, so the committed ref is authoritative.
501
+ const operation = settingsQueueRef.current.then(async () => {
502
+ const settings = typeof update === 'function' ? update(settingsRef.current) : update;
503
+ const saved = await store.updateSettings(settings);
504
+ if (settings.preferredBackend === 'airplay' && settings.preferredAirPlayDevice) {
505
+ // Receiver consent belongs to this acknowledged selection. Publish its
506
+ // ID to the player before applying the Linux session output overlay.
507
+ settingsRef.current = saved.settings;
508
+ await player.selectAirPlayDevice(settings.preferredAirPlayDevice);
509
+ }
510
+ if (settings.preferredBackend && settings.preferredBackend !== 'airplay')
511
+ await player.selectAirPlayDevice(null);
512
+ const nextLibrary = localSessionLibrary(saved, platform, player.airPlaySelectionRequired());
513
+ if (settings.enableNearbyLocation === false) {
514
+ nearbyRequestRef.current += 1;
515
+ discoveryAbortRef.current.nearby?.abort();
516
+ setStationLoading('nearby', false);
517
+ }
518
+ settingsRef.current = nextLibrary.settings;
519
+ setLibrary(nextLibrary);
520
+ setPersistenceWarning(null);
521
+ engine.clearWarning();
522
+ return nextLibrary;
523
+ });
524
+ settingsQueueRef.current = operation.then(() => undefined, () => undefined);
525
+ // Keyboard callbacks can intentionally fire and forget; awaited callers still
526
+ // receive the original rejection and never acknowledge an uncommitted save.
527
+ void operation.catch(reportActionError);
528
+ return operation;
529
+ }, [engine, platform, player, reportActionError, setStationLoading, store]);
398
530
  useEffect(() => {
399
531
  if (!selectedAirPlayDevice?.local || settingsRef.current.preferredBackend !== 'airplay') {
400
532
  return;
401
533
  }
402
534
  const preferredBackend = preferredLocalPlaybackBackend(availableBackends);
403
535
  if (!preferredBackend) {
404
- setMessage(`${selectedAirPlayDevice.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
536
+ setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`);
405
537
  return;
406
538
  }
407
- updateSettings({ preferredBackend });
408
- setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
409
- }, [availableBackends, selectedAirPlayDevice, updateSettings]);
410
- const updateMediaKeys = useCallback((mediaKeys) => {
411
- updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
412
- }, [updateSettings]);
539
+ let changed = false;
540
+ void updateSettings(current => {
541
+ if (current.preferredBackend !== 'airplay' || current.preferredAirPlayDevice !== selectedAirPlayDevice.id)
542
+ return {};
543
+ changed = true;
544
+ return { preferredBackend };
545
+ }).then(() => {
546
+ if (changed)
547
+ setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Audio output: ${audioOutputLabel(preferredBackend)}.`);
548
+ }).catch(() => undefined);
549
+ }, [availableBackends, platform, selectedAirPlayDevice, updateSettings]);
413
550
  const beginLearningTransportKey = useCallback((action) => {
414
551
  setCapturingTransportAction(action);
415
552
  setMessage(`Press a key for ${mediaActionLabel(action)}. Esc cancels.`);
416
553
  }, []);
417
554
  const resetLearnedTransportKeys = useCallback(() => {
418
- updateMediaKeys({ previous: [], playPause: [], next: [] });
419
- setMessage('Learned media keys reset. Built-in fallbacks still work.');
420
- }, [updateMediaKeys]);
555
+ void updateSettings({ mediaKeys: { previous: [], playPause: [], next: [] } })
556
+ .then(() => setMessage('Learned media keys reset. Built-in fallbacks still work.'))
557
+ .catch(() => undefined);
558
+ }, [updateSettings]);
421
559
  const saveLearnedTransportKey = useCallback((action, input) => {
422
- const mediaKeys = addMediaKeyBinding(settingsRef.current.mediaKeys, action, input);
423
- updateMediaKeys(mediaKeys);
424
560
  setCapturingTransportAction(null);
425
- setMessage(`Learned ${mediaActionLabel(action)} key.`);
426
- }, [updateMediaKeys]);
561
+ void updateSettings(current => ({ mediaKeys: addMediaKeyBinding(current.mediaKeys, action, input) }))
562
+ .then(() => setMessage(`Learned ${mediaActionLabel(action)} key.`))
563
+ .catch(() => undefined);
564
+ }, [updateSettings]);
427
565
  const go = useCallback((next, options = {}) => {
428
566
  if (next === 'help' && screenRef.current !== 'help') {
429
567
  helpReturnScreenRef.current = screenRef.current;
@@ -437,27 +575,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
437
575
  screenRef.current !== 'airplay-code') {
438
576
  settingsPageRef.current = 'root';
439
577
  setSettingsPage('root');
440
- itemCountsRef.current.settings = settingsItemsForPage('root').length;
578
+ itemCountsRef.current.settings = settingsItemsForPage('root', airPlaySupported).length;
441
579
  selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
442
580
  }
443
581
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
444
582
  const remembered = selectedByScreenRef.current[destination] ?? 0;
445
- const nextSelection = options.resetSelection ? 0 : remembered;
583
+ const nextSelection = clamp(options.resetSelection ? 0 : remembered, (itemCountsRef.current[destination] ?? 0) - 1);
584
+ // Input can arrive in one batch before React renders the next screen.
585
+ screenRef.current = destination;
586
+ selectedRef.current = nextSelection;
446
587
  setScreen(destination);
447
- setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
588
+ setSelected(nextSelection);
448
589
  if (options.clearMessage !== false) {
449
590
  setMessage(null);
450
591
  }
451
- }, []);
592
+ }, [airPlaySupported]);
452
593
  const openSettingsPage = useCallback((next) => {
453
594
  selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
454
595
  settingsPageRef.current = next;
455
596
  setSettingsPage(next);
456
- const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
597
+ const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next, airPlaySupported).length - 1);
457
598
  selectedRef.current = nextSelection;
458
599
  setSelected(nextSelection);
459
600
  setMessage(null);
460
- }, []);
601
+ }, [airPlaySupported]);
461
602
  const closeSettingsPage = useCallback(() => {
462
603
  if (settingsPageRef.current === 'root') {
463
604
  go('home');
@@ -466,9 +607,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
466
607
  openSettingsPage('root');
467
608
  }, [go, openSettingsPage]);
468
609
  const openAirPlayCode = useCallback(() => {
610
+ if (!airPlaySupported) {
611
+ showTransientMessage(airPlayUnavailableMessage);
612
+ return;
613
+ }
469
614
  setAirPlayCode('');
470
615
  go('airplay-code', { resetSelection: true, clearMessage: false });
471
- }, [go]);
616
+ }, [airPlaySupported, airPlayUnavailableMessage, go, showTransientMessage]);
472
617
  useEffect(() => {
473
618
  if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
474
619
  openAirPlayCode();
@@ -476,13 +621,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
476
621
  }, [openAirPlayCode, playback.backend, playback.message]);
477
622
  const shutdown = useCallback(() => {
478
623
  tuneRequestRef.current += 1;
479
- store.finishActiveListeningSession();
480
- player.stop().finally(exit);
481
- }, [exit, player, store]);
624
+ desktopSuspendedRef.current = true;
625
+ desktopMediaRef.current?.close();
626
+ cancelPendingTune();
627
+ void engine.close().finally(() => store.close()).catch(reportActionError).finally(exit);
628
+ }, [cancelPendingTune, engine, exit, reportActionError, store]);
482
629
  useEffect(() => {
483
630
  const finishForSignal = () => {
484
- store.finishActiveListeningSession();
485
- void player.stop().finally(() => process.exit(0));
631
+ tuneRequestRef.current += 1;
632
+ desktopSuspendedRef.current = true;
633
+ desktopMediaRef.current?.close();
634
+ cancelPendingTune();
635
+ void engine.close().finally(() => store.close()).catch(reportActionError).finally(() => process.exit(0));
486
636
  };
487
637
  process.once('SIGTERM', finishForSignal);
488
638
  process.once('SIGHUP', finishForSignal);
@@ -490,19 +640,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
490
640
  process.off('SIGTERM', finishForSignal);
491
641
  process.off('SIGHUP', finishForSignal);
492
642
  };
493
- }, [player, store]);
494
- const showStationContext = useCallback((context, next = 'stations', options = {}) => {
495
- setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
496
- go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
497
- }, [go, setStationContextFor]);
498
- const loadExploreAt = useCallback(async (cursor, options = {}) => {
643
+ }, [cancelPendingTune, engine, reportActionError, store]);
644
+ const loadExploreAt = useCallback(async (cursor) => {
499
645
  const requestId = exploreRequestRef.current + 1;
500
646
  exploreRequestRef.current = requestId;
501
- setLoadingStations(true);
502
- setMessage(null);
503
- if (screenRef.current !== 'explore') {
504
- go('explore', { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
505
- }
647
+ const signal = beginDiscovery('explore');
648
+ setStationLoading('explore', true);
649
+ if (screenRef.current === 'explore')
650
+ setMessage(null);
506
651
  exploreCursorRef.current = cursor;
507
652
  setExploreCursor(cursor);
508
653
  const previousExploreStations = stationContextsRef.current.explore.stations;
@@ -513,7 +658,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
513
658
  error: undefined
514
659
  });
515
660
  try {
516
- const stations = await providers.nearby(exploreCursorLocation(cursor), 90);
661
+ const stations = await providers.nearby(exploreCursorLocation(cursor), 90, { signal });
517
662
  if (requestId !== exploreRequestRef.current) {
518
663
  return;
519
664
  }
@@ -526,7 +671,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
526
671
  if (screenRef.current === 'explore') {
527
672
  setSelected(0);
528
673
  }
529
- if (stations.length === 0) {
674
+ if (stations.length === 0 && screenRef.current === 'explore') {
530
675
  setMessage(`No geotagged stations found near ${formatExploreCursor(cursor)}.`);
531
676
  }
532
677
  }
@@ -540,23 +685,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
540
685
  subtitle: `World station directory unavailable near ${formatExploreCursor(cursor)}`,
541
686
  error: loadError
542
687
  });
543
- setMessage(loadError);
688
+ if (screenRef.current === 'explore')
689
+ setMessage(loadError);
544
690
  }
545
691
  finally {
546
692
  if (requestId === exploreRequestRef.current) {
547
- setLoadingStations(false);
693
+ setStationLoading('explore', false);
548
694
  }
549
695
  }
550
- }, [go, providers, setStationContextFor]);
696
+ }, [beginDiscovery, providers, setStationContextFor, setStationLoading]);
551
697
  const loadExplore = useCallback(async () => {
552
- await loadExploreAt(exploreCursorRef.current, { resetSelection: true });
698
+ await loadExploreAt(exploreCursorRef.current);
553
699
  }, [loadExploreAt]);
554
700
  const moveExploreMapCursor = useCallback((direction, fast = false) => {
555
701
  const next = shiftExploreCursor(exploreCursorRef.current, direction, fast);
702
+ exploreRequestRef.current += 1;
556
703
  exploreCursorRef.current = next;
557
704
  setExploreCursor(next);
558
705
  setSelected(0);
559
- setLoadingStations(true);
706
+ setStationLoading('explore', true);
560
707
  setStationContextFor('explore', {
561
708
  title: 'Explore world',
562
709
  subtitle: `Move cursor: ${formatExploreCursor(next)}`,
@@ -566,9 +713,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
566
713
  clearTimeout(exploreMoveTimerRef.current);
567
714
  }
568
715
  exploreMoveTimerRef.current = setTimeout(() => {
569
- void loadExploreAt(next, { resetSelection: true, clearMessage: false });
716
+ exploreMoveTimerRef.current = null;
717
+ void loadExploreAt(next);
570
718
  }, 220);
571
- }, [loadExploreAt, setStationContextFor]);
719
+ }, [loadExploreAt, setStationContextFor, setStationLoading]);
572
720
  const moveExploreMapCursorToCell = useCallback((x, y) => {
573
721
  const next = exploreCursorForMouseCell(x, y, frameWidth, layout);
574
722
  if (!next) {
@@ -576,84 +724,112 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
576
724
  }
577
725
  if (exploreMoveTimerRef.current) {
578
726
  clearTimeout(exploreMoveTimerRef.current);
727
+ exploreMoveTimerRef.current = null;
579
728
  }
580
- void loadExploreAt(next, { resetSelection: true, clearMessage: false });
729
+ void loadExploreAt(next);
581
730
  }, [frameWidth, layout, loadExploreAt]);
582
- const loadCountry = useCallback(async (country) => {
731
+ const loadCountry = useCallback(async (country, navigate = true) => {
732
+ const previous = stationContextsRef.current.stations;
733
+ if (previous.country?.code === country.code && loadingStationContextsRef.current.stations && !countryPageRequestRef.current) {
734
+ if (navigate)
735
+ go('stations');
736
+ return;
737
+ }
738
+ const signal = beginDiscovery('stations');
583
739
  const requestId = countryRequestRef.current + 1;
584
740
  countryRequestRef.current = requestId;
585
- setLoadingStations(true);
586
- setMessage(null);
741
+ setStationLoading('stations', true);
587
742
  countryPageRequestRef.current = null;
743
+ const retainedStations = previous.country?.code === country.code ? previous.stations : [];
744
+ setStationContextFor('stations', {
745
+ title: country.name,
746
+ subtitle: formatCountryStationsSubtitle(country, retainedStations.length, false),
747
+ stations: retainedStations,
748
+ country
749
+ });
750
+ if (navigate)
751
+ go('stations', { resetSelection: true });
588
752
  try {
589
- const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
753
+ const page = typeof providers.byCountryPage === 'function'
754
+ ? await providers.byCountryPage(country.code, { limit: COUNTRY_STATIONS_PAGE_SIZE, signal })
755
+ : legacyDiscoveryPage(await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0), COUNTRY_STATIONS_PAGE_SIZE);
756
+ const stations = page.items;
590
757
  if (requestId !== countryRequestRef.current)
591
758
  return;
592
- showStationContext({
759
+ setStationContextFor('stations', {
593
760
  title: country.name,
594
- subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
761
+ subtitle: formatCountryStationsSubtitle(country, stations.length, !page.exhausted),
595
762
  stations,
596
763
  country,
597
- hasMore: stations.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount
598
- }, 'stations');
764
+ hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources,
765
+ error: discoverySourceWarning(page.sources)
766
+ });
767
+ selectedByScreenRef.current.stations = 0;
768
+ if (screenRef.current === 'stations')
769
+ setSelected(0);
599
770
  }
600
771
  catch (error) {
601
772
  if (requestId !== countryRequestRef.current)
602
773
  return;
603
- setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`);
774
+ const loadError = error instanceof Error ? error.message : `Could not load ${country.name}.`;
775
+ setStationContextFor('stations', { ...stationContextsRef.current.stations, error: loadError });
776
+ if (screenRef.current === 'stations')
777
+ setMessage(loadError);
604
778
  }
605
779
  finally {
606
780
  if (requestId === countryRequestRef.current)
607
- setLoadingStations(false);
781
+ setStationLoading('stations', false);
608
782
  }
609
- }, [providers, showStationContext]);
783
+ }, [beginDiscovery, go, providers, setStationContextFor, setStationLoading]);
610
784
  const loadMoreCountryStations = useCallback(async () => {
611
785
  const context = stationContextsRef.current.stations;
612
786
  const country = context.country;
613
- if (!country || !context.hasMore || loadingStationsRef.current) {
787
+ if (!country || !context.hasMore || loadingStationContextsRef.current.stations) {
614
788
  return;
615
789
  }
616
790
  const offset = context.stations.length;
617
- const requestKey = `${country.code}:${offset}`;
791
+ const requestId = countryRequestRef.current;
792
+ const requestKey = `${requestId}:${country.code}:${context.nextCursor ?? offset}`;
618
793
  if (countryPageRequestRef.current === requestKey) {
619
794
  return;
620
795
  }
621
796
  countryPageRequestRef.current = requestKey;
622
- setLoadingStations(true);
797
+ setStationLoading('stations', true);
623
798
  try {
624
- const page = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset);
799
+ const page = typeof providers.byCountryPage === 'function'
800
+ ? await providers.byCountryPage(country.code, { limit: COUNTRY_STATIONS_PAGE_SIZE, cursor: context.nextCursor, signal: discoveryAbortRef.current.stations?.signal })
801
+ : legacyDiscoveryPage(await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset), COUNTRY_STATIONS_PAGE_SIZE);
625
802
  const latest = stationContextsRef.current.stations;
626
- if (latest.country?.code !== country.code || latest.stations.length !== offset) {
803
+ if (requestId !== countryRequestRef.current || latest.country?.code !== country.code || latest.stations.length !== offset) {
627
804
  return;
628
805
  }
629
- const stations = appendUniqueStations(latest.stations, page);
630
- const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE &&
631
- stations.length > latest.stations.length &&
632
- stations.length < country.stationCount;
806
+ const stations = appendUniqueStations(latest.stations, page.items);
807
+ const hasMore = !page.exhausted;
633
808
  setStationContextFor('stations', {
634
809
  ...latest,
635
810
  subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
636
811
  stations,
637
- hasMore
812
+ hasMore, nextCursor: page.nextCursor, sources: page.sources,
813
+ error: discoverySourceWarning(page.sources)
638
814
  });
639
815
  }
640
816
  catch (error) {
641
- if (stationContextsRef.current.stations.country?.code === country.code) {
817
+ if (requestId === countryRequestRef.current && screenRef.current === 'stations' && stationContextsRef.current.stations.country?.code === country.code) {
642
818
  setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
643
819
  }
644
820
  }
645
821
  finally {
646
822
  if (countryPageRequestRef.current === requestKey) {
647
823
  countryPageRequestRef.current = null;
648
- setLoadingStations(false);
824
+ setStationLoading('stations', false);
649
825
  }
650
826
  }
651
- }, [providers, setStationContextFor]);
827
+ }, [providers, setStationContextFor, setStationLoading]);
652
828
  useEffect(() => {
653
829
  if (screen === 'stations' &&
654
830
  stationContexts.stations.country &&
655
831
  stationContexts.stations.hasMore &&
656
- stationContexts.stations.stations.length - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
832
+ stationCounts.stations - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
657
833
  void loadMoreCountryStations();
658
834
  }
659
835
  }, [
@@ -662,101 +838,128 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
662
838
  selected,
663
839
  stationContexts.stations.country,
664
840
  stationContexts.stations.hasMore,
665
- stationContexts.stations.stations.length
841
+ stationCounts.stations,
842
+ stationContexts.stations.nextCursor
666
843
  ]);
667
844
  const runSearch = useCallback(async (query = searchQuery) => {
668
845
  if (!query.trim()) {
669
846
  setMessage('Enter a station, genre, language, or place.');
670
847
  return;
671
848
  }
849
+ const signal = beginDiscovery('search');
672
850
  const requestId = searchRequestRef.current + 1;
673
851
  searchRequestRef.current = requestId;
674
- setLoadingStations(true);
675
- setMessage(null);
852
+ activeSearchRef.current = { query: query.trim(), fingerprint: searchFingerprint };
853
+ searchPageRequestRef.current = null;
854
+ setStationLoading('search', true);
855
+ if (screenRef.current === 'search')
856
+ setMessage(null);
676
857
  try {
677
- const stations = await providers.search(query, settingsRef.current, {
858
+ const pageOptions = {
678
859
  limit: SEARCH_RESULTS_PAGE_SIZE,
679
- offset: 0,
860
+ signal,
680
861
  codec: filters.codec ?? undefined,
681
862
  language: filters.language ?? undefined,
682
863
  minBitrate: filters.minBitrate ?? undefined
683
- });
864
+ };
865
+ const page = typeof providers.searchPage === 'function'
866
+ ? await providers.searchPage(query, settingsRef.current, pageOptions)
867
+ : legacyDiscoveryPage(await providers.search(query, settingsRef.current, pageOptions), SEARCH_RESULTS_PAGE_SIZE);
868
+ const stations = page.items;
684
869
  if (requestId !== searchRequestRef.current)
685
870
  return;
871
+ searchContextFingerprintRef.current = searchFingerprint;
686
872
  setStationContextFor('search', {
687
873
  title: `Search: ${query}`,
688
- subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
874
+ subtitle: formatSearchSubtitle(stations.length, !page.exhausted),
689
875
  stations,
690
876
  query: query.trim(),
691
- hasMore: stations.length >= SEARCH_RESULTS_PAGE_SIZE
877
+ hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources,
878
+ error: discoverySourceWarning(page.sources)
692
879
  });
693
880
  selectedByScreenRef.current.search = 0;
694
881
  lastSubmittedSearchRef.current = query.trim();
695
- setLibrary(store.addSearch(query));
882
+ persistLibrary(() => store.addSearch(query));
696
883
  searchHistoryRef.current = { cursor: -1, draft: '' };
697
- setSelected(0);
698
- setEditingSearch(true);
884
+ if (screenRef.current === 'search') {
885
+ setSelected(0);
886
+ setEditingSearch(true);
887
+ }
699
888
  }
700
889
  catch (error) {
701
890
  if (requestId !== searchRequestRef.current)
702
891
  return;
703
- setMessage(error instanceof Error ? error.message : 'Search failed.');
892
+ if (screenRef.current === 'search')
893
+ setMessage(error instanceof Error ? error.message : 'Search failed.');
704
894
  }
705
895
  finally {
706
896
  if (requestId === searchRequestRef.current)
707
- setLoadingStations(false);
897
+ setStationLoading('search', false);
708
898
  }
709
- }, [filters, providers, searchQuery, setStationContextFor, store]);
899
+ }, [beginDiscovery, filters, persistLibrary, providers, searchFingerprint, searchQuery, setStationContextFor, setStationLoading, store]);
900
+ useEffect(() => {
901
+ const active = activeSearchRef.current;
902
+ // A cursor belongs to the exact server filters and enabled directories.
903
+ // Reissue the submitted query, including when a previous request is pending.
904
+ if (active && active.fingerprint !== searchFingerprint)
905
+ void runSearch(active.query);
906
+ }, [runSearch, searchFingerprint]);
710
907
  const loadMoreSearchResults = useCallback(async () => {
711
908
  const context = stationContextsRef.current.search;
712
909
  const query = context.query;
713
- if (!query || !context.hasMore || loadingStationsRef.current) {
910
+ if (!query || !context.hasMore || loadingStationContextsRef.current.search || searchContextFingerprintRef.current !== searchFingerprint) {
714
911
  return;
715
912
  }
716
913
  const offset = context.stations.length;
717
- const requestKey = `${query}:${offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
914
+ const requestId = searchRequestRef.current;
915
+ const requestKey = `${requestId}:${query}:${context.nextCursor ?? offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
718
916
  if (searchPageRequestRef.current === requestKey) {
719
917
  return;
720
918
  }
721
919
  searchPageRequestRef.current = requestKey;
722
- setLoadingStations(true);
920
+ setStationLoading('search', true);
723
921
  try {
724
- const page = await providers.search(query, settingsRef.current, {
922
+ const pageOptions = {
725
923
  limit: SEARCH_RESULTS_PAGE_SIZE,
726
- offset,
924
+ cursor: context.nextCursor,
925
+ signal: discoveryAbortRef.current.search?.signal,
727
926
  codec: filters.codec ?? undefined,
728
927
  language: filters.language ?? undefined,
729
928
  minBitrate: filters.minBitrate ?? undefined
730
- });
929
+ };
930
+ const page = typeof providers.searchPage === 'function'
931
+ ? await providers.searchPage(query, settingsRef.current, pageOptions)
932
+ : legacyDiscoveryPage(await providers.search(query, settingsRef.current, { ...pageOptions, offset }), SEARCH_RESULTS_PAGE_SIZE);
731
933
  const latest = stationContextsRef.current.search;
732
- if (latest.query !== query || latest.stations.length !== offset) {
934
+ if (requestId !== searchRequestRef.current || latest.query !== query || latest.stations.length !== offset) {
733
935
  return;
734
936
  }
735
- const stations = appendUniqueStations(latest.stations, page);
736
- const hasMore = page.length >= SEARCH_RESULTS_PAGE_SIZE && stations.length > latest.stations.length;
937
+ const stations = appendUniqueStations(latest.stations, page.items);
938
+ const hasMore = !page.exhausted;
737
939
  setStationContextFor('search', {
738
940
  ...latest,
739
941
  subtitle: formatSearchSubtitle(stations.length, hasMore),
740
942
  stations,
741
- hasMore
943
+ hasMore, nextCursor: page.nextCursor, sources: page.sources,
944
+ error: discoverySourceWarning(page.sources)
742
945
  });
743
946
  }
744
947
  catch (error) {
745
- if (stationContextsRef.current.search.query === query) {
948
+ if (requestId === searchRequestRef.current && screenRef.current === 'search' && stationContextsRef.current.search.query === query) {
746
949
  setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
747
950
  }
748
951
  }
749
952
  finally {
750
953
  if (searchPageRequestRef.current === requestKey) {
751
954
  searchPageRequestRef.current = null;
752
- setLoadingStations(false);
955
+ setStationLoading('search', false);
753
956
  }
754
957
  }
755
- }, [filters, providers, setStationContextFor]);
958
+ }, [filters, providers, searchFingerprint, setStationContextFor, setStationLoading]);
756
959
  useEffect(() => {
757
960
  if (screen === 'search' &&
758
961
  stationContexts.search.hasMore &&
759
- stationContexts.search.stations.length - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
962
+ stationCounts.search - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
760
963
  void loadMoreSearchResults();
761
964
  }
762
965
  }, [
@@ -764,7 +967,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
764
967
  screen,
765
968
  selected,
766
969
  stationContexts.search.hasMore,
767
- stationContexts.search.stations.length
970
+ stationCounts.search,
971
+ stationContexts.search.nextCursor
768
972
  ]);
769
973
  const recallSearchHistory = useCallback((direction) => {
770
974
  const history = store.snapshot().searchHistory;
@@ -789,19 +993,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
789
993
  }
790
994
  }, [searchQuery, store]);
791
995
  const loadNearby = useCallback(async () => {
996
+ const signal = beginDiscovery('nearby');
792
997
  const requestId = nearbyRequestRef.current + 1;
793
998
  nearbyRequestRef.current = requestId;
794
- setLoadingStations(true);
795
- setMessage(null);
999
+ setStationLoading('nearby', true);
1000
+ if (screenRef.current === 'nearby')
1001
+ setMessage(null);
796
1002
  setStationContextFor('nearby', {
797
1003
  ...stationContextsRef.current.nearby,
798
1004
  error: undefined
799
1005
  });
800
- go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
801
1006
  try {
802
1007
  if (!settingsRef.current.enableNearbyLocation) {
803
1008
  if (stationContextsRef.current.nearby.stations.length > 0) {
804
- setMessage('Nearby location lookup is off. Showing the last nearby station list.');
1009
+ if (screenRef.current === 'nearby')
1010
+ setMessage('Nearby location lookup is off. Showing the last nearby station list.');
805
1011
  return;
806
1012
  }
807
1013
  setStationContextFor('nearby', {
@@ -812,13 +1018,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
812
1018
  });
813
1019
  return;
814
1020
  }
815
- const detected = location ?? (await providers.detectLocation());
1021
+ const detected = location ?? (await providers.detectLocation({ signal }));
816
1022
  if (requestId !== nearbyRequestRef.current)
817
1023
  return;
818
1024
  setLocation(detected);
819
1025
  if (!detected) {
820
1026
  if (stationContextsRef.current.nearby.stations.length > 0) {
821
- setMessage('Location detection is unavailable. Showing the last nearby station list.');
1027
+ if (screenRef.current === 'nearby')
1028
+ setMessage('Location detection is unavailable. Showing the last nearby station list.');
822
1029
  return;
823
1030
  }
824
1031
  setStationContextFor('nearby', {
@@ -829,7 +1036,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
829
1036
  });
830
1037
  return;
831
1038
  }
832
- const stations = await providers.nearby(detected, 90);
1039
+ const stations = await providers.nearby(detected, 90, { signal });
833
1040
  if (requestId !== nearbyRequestRef.current)
834
1041
  return;
835
1042
  setStationContextFor('nearby', {
@@ -846,17 +1053,20 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
846
1053
  ...stationContextsRef.current.nearby,
847
1054
  error: loadError
848
1055
  });
849
- setMessage(loadError);
1056
+ if (screenRef.current === 'nearby')
1057
+ setMessage(loadError);
850
1058
  }
851
1059
  finally {
852
1060
  if (requestId === nearbyRequestRef.current)
853
- setLoadingStations(false);
1061
+ setStationLoading('nearby', false);
854
1062
  }
855
- }, [go, location, providers, setStationContextFor]);
1063
+ }, [beginDiscovery, location, providers, setStationContextFor, setStationLoading]);
856
1064
  const queueFromCurrentList = useCallback((station) => {
857
1065
  const sourceScreen = screenRef.current;
858
1066
  const sourceContextKey = stationContextKeyForScreen(sourceScreen);
859
- const currentList = displayStationsRef.current;
1067
+ const currentList = sourceContextKey
1068
+ ? applyStationFilters(stationContextsRef.current[sourceContextKey].stations, filters)
1069
+ : displayStationsRef.current;
860
1070
  if (currentList.some(item => stationMatches(item, station))) {
861
1071
  return {
862
1072
  title: sourceContextKey ? stationContextsRef.current[sourceContextKey].title : 'Current station list',
@@ -874,7 +1084,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
874
1084
  sourceContextKey: null,
875
1085
  stations: [station]
876
1086
  };
877
- }, [queueContainsStation, stationMatches]);
1087
+ }, [filters, queueContainsStation, stationMatches]);
878
1088
  const rememberQueueSelection = useCallback((queue, index) => {
879
1089
  if (queue.sourceContextKey) {
880
1090
  selectedByScreenRef.current[queue.sourceScreen] = index;
@@ -885,68 +1095,45 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
885
1095
  }
886
1096
  }, []);
887
1097
  const playStation = useCallback(async (station, options = {}) => {
888
- const requestId = tuneRequestRef.current + 1;
889
- tuneRequestRef.current = requestId;
890
- if (skipBrokenTimerRef.current) {
891
- clearTimeout(skipBrokenTimerRef.current);
892
- skipBrokenTimerRef.current = null;
893
- }
1098
+ if (options.signal?.aborted)
1099
+ return;
1100
+ if (!options.outputChange)
1101
+ outputCancellationRef.current += 1;
1102
+ const requestId = ++tuneRequestRef.current;
1103
+ const pendingSettings = settingsQueueRef.current;
894
1104
  const queue = options.queue ?? queueFromCurrentList(station);
895
- setTuningStation(station);
896
- setNowPlaying(null);
897
1105
  try {
898
- // Cancel any in-flight backend startup before resolving the next tune.
899
- // This prevents a slower, older request from becoming audible after a
900
- // newer station was selected.
901
- await player.stop();
902
- if (requestId !== tuneRequestRef.current)
903
- return;
904
- const resolved = await providers.resolve(station);
905
- if (requestId !== tuneRequestRef.current)
1106
+ await pendingSettings;
1107
+ if (requestId !== tuneRequestRef.current || options.signal?.aborted)
906
1108
  return;
907
- await player.play(station, resolved.url);
1109
+ desktopSuspendedRef.current = false;
1110
+ playbackQueueRef.current = queue;
1111
+ const tuned = await engine.tune(station, { queue: queue.stations, startPaused: options.startPaused, signal: options.signal });
908
1112
  if (requestId !== tuneRequestRef.current)
909
1113
  return;
910
- playingStationRef.current = station;
911
- setPlayingStation(station);
912
- setTuningStation(null);
913
- playbackQueueRef.current = queue;
914
- const nextLibrary = store.addRecent(station);
1114
+ const index = queue.stations.findIndex(item => stationMatches(item, tuned));
1115
+ if (index >= 0 && !stationMatches(tuned, station))
1116
+ rememberQueueSelection(queue, index);
1117
+ desktopMediaRef.current?.refresh();
915
1118
  if (screenRef.current === 'library') {
916
- const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
917
- const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station));
918
- if (nextLibraryIndex >= 0) {
919
- selectedByScreenRef.current.library = nextLibraryIndex;
920
- setSelected(nextLibraryIndex);
1119
+ const nextLibraryStations = applyStationFilters(buildLibraryStations(store.getSnapshot()), filters);
1120
+ const nextIndex = nextLibraryStations.findIndex(item => stationMatches(item, tuned));
1121
+ if (nextIndex >= 0) {
1122
+ selectedByScreenRef.current.library = nextIndex;
1123
+ setSelected(nextIndex);
921
1124
  }
922
1125
  }
923
- setLibrary(nextLibrary);
924
- if (options.openNowPlaying) {
1126
+ if (options.openNowPlaying)
925
1127
  go('now-playing');
926
- }
927
1128
  setMessage(null);
1129
+ return tuned;
928
1130
  }
929
1131
  catch (error) {
930
- if (requestId !== tuneRequestRef.current)
931
- return;
932
- const message = error instanceof Error ? error.message : 'Could not tune station.';
933
- const currentList = queue.stations;
934
- const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
935
- const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
936
- if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
937
- setMessage(`${message} Skipping to ${nextStation.name}.`);
938
- setTuningStation(nextStation);
939
- rememberQueueSelection(queue, currentIndex + 1);
940
- skipBrokenTimerRef.current = setTimeout(() => {
941
- skipBrokenTimerRef.current = null;
942
- playStationRef.current(nextStation, { ...options, queue });
943
- }, BROKEN_STREAM_AUTO_SKIP_DELAY_MS);
1132
+ if (requestId !== tuneRequestRef.current || options.signal?.aborted || error.name === 'AbortError')
944
1133
  return;
945
- }
946
- setTuningStation(null);
947
- setMessage(message);
1134
+ setMessage(error instanceof Error ? error.message : 'Could not tune station.');
948
1135
  }
949
- }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
1136
+ }, [engine, filters, go, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
950
1137
  // Resume the most recent station on launch when opted in, like a radio
951
1138
  // powering back on to its last frequency. Runs once and never auto-navigates.
952
1139
  const didResumeRef = useRef(false);
@@ -955,6 +1142,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
955
1142
  return;
956
1143
  }
957
1144
  didResumeRef.current = true;
1145
+ if (playbackSession?.status.station || playbackSession?.status.tui?.tuningStation)
1146
+ return;
958
1147
  if (!settingsRef.current.resumeOnLaunch) {
959
1148
  return;
960
1149
  }
@@ -962,17 +1151,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
962
1151
  if (last) {
963
1152
  void playStation(last);
964
1153
  }
965
- }, [playStation, store]);
966
- playStationRef.current = playStation;
1154
+ }, [playStation, store, playbackSession]);
967
1155
  const cancelPendingAutoSkip = useCallback(() => {
968
- if (!skipBrokenTimerRef.current)
1156
+ if (!engine.snapshot().message?.includes('Skipping to'))
969
1157
  return;
970
- clearTimeout(skipBrokenTimerRef.current);
971
- skipBrokenTimerRef.current = null;
972
- setTuningStation(null);
1158
+ cancelPendingTune();
973
1159
  setMessage('Auto-skip canceled. Choose a station and press Enter.');
974
- }, []);
975
- const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
1160
+ }, [cancelPendingTune, engine]);
1161
+ const showTransientFooterMessage = useCallback((nextMessage, durationMs = TRANSIENT_NOTICE_MS) => {
976
1162
  if (transientFooterMessageTimerRef.current) {
977
1163
  clearTimeout(transientFooterMessageTimerRef.current);
978
1164
  }
@@ -992,37 +1178,48 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
992
1178
  showTransientFooterMessage('Ctrl+C again to exit', EXIT_CONFIRMATION_MS);
993
1179
  }, [showTransientFooterMessage, shutdown]);
994
1180
  const toggleFavorite = useCallback((station) => {
995
- if (!station) {
996
- setMessage('Select or play a station before pressing f.');
997
- return;
998
- }
999
- const wasFavorite = store.isFavorite(station);
1000
- setLibrary(store.toggleFavorite(station));
1001
- const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
1002
- if (screenRef.current === 'library') {
1003
- showTransientFooterMessage(favoriteMessage);
1004
- }
1005
- else {
1006
- setMessage(favoriteMessage);
1007
- }
1008
- if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
1009
- // Best-effort upvote back to the directory; never blocks favoriting.
1010
- void providers.vote(station);
1011
- }
1012
- }, [providers, showTransientFooterMessage, store]);
1181
+ const operation = (async () => {
1182
+ if (!station) {
1183
+ setMessage('Select or play a station before pressing f.');
1184
+ return;
1185
+ }
1186
+ const wasFavorite = store.isFavorite(station);
1187
+ setLibrary(localSessionLibrary(await store.toggleFavorite(station), platform, player.airPlaySelectionRequired()));
1188
+ setPersistenceWarning(null);
1189
+ engine.clearWarning();
1190
+ const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
1191
+ if (screenRef.current === 'library' || screenRef.current === 'search') {
1192
+ showTransientFooterMessage(favoriteMessage);
1193
+ }
1194
+ else {
1195
+ showTransientMessage(favoriteMessage);
1196
+ }
1197
+ if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
1198
+ // Best-effort upvote back to the directory; never blocks favoriting.
1199
+ void providers.vote(station).catch(reportActionError);
1200
+ }
1201
+ })();
1202
+ void operation.catch(reportActionError);
1203
+ return operation;
1204
+ }, [engine, platform, player, providers, reportActionError, showTransientFooterMessage, showTransientMessage, store]);
1013
1205
  const showControlResult = useCallback((result) => {
1014
1206
  if (!result.ok && result.message) {
1015
1207
  setMessage(result.message);
1016
1208
  }
1017
1209
  }, []);
1018
- const openStationHomepage = useCallback((station) => {
1210
+ const openStationHomepage = useCallback(async (station) => {
1019
1211
  if (!station?.homepage) {
1020
1212
  setMessage('This station has no homepage.');
1021
1213
  return;
1022
1214
  }
1023
- setMessage(openExternal(station.homepage)
1215
+ const url = safeExternalHttpUrl(station.homepage);
1216
+ if (!url) {
1217
+ setMessage('This station has no valid HTTP(S) homepage.');
1218
+ return;
1219
+ }
1220
+ setMessage(await openExternal(url)
1024
1221
  ? `Opening homepage: ${station.name}`
1025
- : 'That station homepage is not a valid HTTP(S) URL.');
1222
+ : `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}`);
1026
1223
  }, []);
1027
1224
  const copyStationUrl = useCallback(async (station) => {
1028
1225
  if (!station) {
@@ -1033,23 +1230,29 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1033
1230
  if (!url) {
1034
1231
  url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
1035
1232
  }
1036
- if (!url) {
1037
- setMessage(`No stream URL available for ${station.name}.`);
1233
+ const safeUrl = url ? safeMediaTarget(url) : null;
1234
+ if (!safeUrl) {
1235
+ setMessage(`No safe stream URL available for ${station.name}.`);
1038
1236
  return;
1039
1237
  }
1040
- const copied = copyToClipboard(url);
1041
- setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`);
1238
+ const copied = await copyToClipboard(safeUrl);
1239
+ setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`);
1042
1240
  }, [providers]);
1043
- const submitAirPlayCode = useCallback((code) => {
1044
- const result = player.submitAirPlayPasscode(code);
1045
- if (result.ok) {
1046
- setAirPlayCode('');
1047
- setMessage(result.message ?? 'AirPlay code sent.');
1048
- go('now-playing', { clearMessage: false });
1049
- return;
1241
+ const submitAirPlayCode = useCallback(async (code) => {
1242
+ try {
1243
+ const result = await player.submitAirPlayPasscode(code);
1244
+ if (result.ok) {
1245
+ setAirPlayCode('');
1246
+ setMessage(result.message ?? 'AirPlay code sent.');
1247
+ go('now-playing', { clearMessage: false });
1248
+ return;
1249
+ }
1250
+ showControlResult(result);
1050
1251
  }
1051
- showControlResult(result);
1052
- }, [go, player, showControlResult]);
1252
+ catch (error) {
1253
+ reportActionError(error);
1254
+ }
1255
+ }, [go, player, showControlResult, reportActionError]);
1053
1256
  const setVolume = useCallback((volume) => {
1054
1257
  const clamped = clampVolume(volume);
1055
1258
  const requestId = volumeRequestRef.current + 1;
@@ -1059,8 +1262,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1059
1262
  updateSettings({ volume: player.getState().volume });
1060
1263
  }
1061
1264
  showControlResult(result);
1062
- });
1063
- }, [player, showControlResult, updateSettings]);
1265
+ }).catch(reportActionError);
1266
+ }, [player, reportActionError, showControlResult, updateSettings]);
1064
1267
  const adjustVolume = useCallback((delta) => {
1065
1268
  const requestId = volumeRequestRef.current + 1;
1066
1269
  volumeRequestRef.current = requestId;
@@ -1069,62 +1272,66 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1069
1272
  updateSettings({ volume: player.getState().volume });
1070
1273
  }
1071
1274
  showControlResult(result);
1072
- });
1073
- }, [player, showControlResult, updateSettings]);
1275
+ }).catch(reportActionError);
1276
+ }, [player, reportActionError, showControlResult, updateSettings]);
1074
1277
  const toggleMute = useCallback(() => {
1075
- void player.toggleMute().then(showControlResult);
1076
- }, [player, showControlResult]);
1278
+ void player.toggleMute().then(showControlResult).catch(reportActionError);
1279
+ }, [player, reportActionError, showControlResult]);
1077
1280
  const togglePause = useCallback(() => {
1078
- void player.togglePause().then(showControlResult);
1079
- }, [player, showControlResult]);
1080
- const showTransientMessage = useCallback((nextMessage) => {
1081
- if (transientMessageTimerRef.current) {
1082
- clearTimeout(transientMessageTimerRef.current);
1083
- }
1084
- setMessage(nextMessage);
1085
- transientMessageTimerRef.current = setTimeout(() => {
1086
- setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
1087
- transientMessageTimerRef.current = null;
1088
- }, VISUALIZER_MESSAGE_MS);
1089
- }, []);
1281
+ void player.togglePause().then(showControlResult).catch(reportActionError);
1282
+ }, [player, reportActionError, showControlResult]);
1090
1283
  const cycleDisplayColor = useCallback(() => {
1091
- const theme = nextTheme(settingsRef.current.theme);
1092
- updateSettings({ theme });
1093
- showTransientMessage(`Display color: ${theme}`);
1284
+ void updateSettings(current => ({ theme: nextTheme(current.theme) }))
1285
+ .then(saved => showTransientMessage(`Display color: ${saved.settings.theme}`)).catch(() => undefined);
1094
1286
  }, [showTransientMessage, updateSettings]);
1095
1287
  const cycleReceiverStyle = useCallback(() => {
1096
- const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
1097
- updateSettings({ receiverStyle });
1098
- showTransientMessage(`Receiver style: ${receiverStyle}`);
1288
+ void updateSettings(current => ({ receiverStyle: nextReceiverStyle(current.receiverStyle) }))
1289
+ .then(saved => showTransientMessage(`Receiver style: ${saved.settings.receiverStyle}`)).catch(() => undefined);
1099
1290
  }, [showTransientMessage, updateSettings]);
1100
1291
  const toggleRadioGarden = useCallback(() => {
1101
- const enableRadioGarden = !settingsRef.current.enableRadioGarden;
1102
- updateSettings({ enableRadioGarden });
1103
- setMessage(`Radio Garden ${enableRadioGarden ? 'enabled' : 'disabled'}.`);
1104
- setTimeout(refreshProviderHealth, 0);
1105
- }, [refreshProviderHealth, updateSettings]);
1292
+ void (async () => {
1293
+ const { settings: { enableRadioGarden } } = await updateSettings(current => ({ enableRadioGarden: !current.enableRadioGarden }));
1294
+ setMessage(`Radio Garden ${enableRadioGarden ? 'enabled' : 'disabled'}.`);
1295
+ setTimeout(refreshProviderHealth, 0);
1296
+ })().catch(reportActionError);
1297
+ }, [refreshProviderHealth, reportActionError, updateSettings]);
1106
1298
  const toggleNearbyLocation = useCallback(() => {
1107
- const enableNearbyLocation = !settingsRef.current.enableNearbyLocation;
1108
- updateSettings({ enableNearbyLocation });
1109
- setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
1110
- if (enableNearbyLocation && screenRef.current === 'nearby') {
1111
- setTimeout(() => void loadNearby(), 0);
1112
- }
1113
- else if (!enableNearbyLocation && screenRef.current === 'nearby') {
1114
- const current = stationContextsRef.current.nearby;
1115
- setStationContextFor('nearby', {
1116
- ...current,
1117
- subtitle: 'Location lookup off · showing the last loaded results',
1118
- error: undefined
1119
- });
1120
- }
1121
- }, [loadNearby, setStationContextFor, updateSettings]);
1299
+ void (async () => {
1300
+ const { settings: { enableNearbyLocation } } = await updateSettings(current => ({ enableNearbyLocation: !current.enableNearbyLocation }));
1301
+ setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
1302
+ if (enableNearbyLocation && screenRef.current === 'nearby') {
1303
+ setTimeout(() => void loadNearby(), 0);
1304
+ }
1305
+ else if (!enableNearbyLocation && screenRef.current === 'nearby') {
1306
+ const current = stationContextsRef.current.nearby;
1307
+ setStationContextFor('nearby', {
1308
+ ...current,
1309
+ subtitle: 'Location lookup off · showing the last loaded results',
1310
+ error: undefined
1311
+ });
1312
+ }
1313
+ })().catch(reportActionError);
1314
+ }, [loadNearby, reportActionError, setStationContextFor, updateSettings]);
1122
1315
  const toggleDirectoryVoting = useCallback(() => {
1123
- const shareDirectoryVotes = !settingsRef.current.shareDirectoryVotes;
1124
- updateSettings({ shareDirectoryVotes });
1125
- setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
1126
- }, [updateSettings]);
1316
+ void (async () => {
1317
+ const { settings: { shareDirectoryVotes } } = await updateSettings(current => ({ shareDirectoryVotes: !current.shareDirectoryVotes }));
1318
+ setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
1319
+ })().catch(reportActionError);
1320
+ }, [reportActionError, updateSettings]);
1127
1321
  const refreshAirPlayTargets = useCallback(async (announce = true) => {
1322
+ if (!airPlaySupported) {
1323
+ if (announce)
1324
+ showTransientMessage(airPlayUnavailableMessage);
1325
+ return [];
1326
+ }
1327
+ if (platform === 'linux' && !player.refreshDetectedBackends().includes('airplay')) {
1328
+ setMessage(airPlaySetupRequiredMessage);
1329
+ throw new PlaybackOutputError(airPlaySetupRequiredMessage);
1330
+ }
1331
+ if (networkPolicy().offline) {
1332
+ setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.');
1333
+ return [];
1334
+ }
1128
1335
  try {
1129
1336
  const devices = await player.refreshAirPlayDevices();
1130
1337
  setAvailableAirPlayDevices(devices);
@@ -1140,98 +1347,113 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1140
1347
  }
1141
1348
  return devices;
1142
1349
  }
1143
- catch {
1350
+ catch (error) {
1144
1351
  setAvailableAirPlayDevices([]);
1145
- if (announce) {
1146
- setMessage('AirPlay receiver refresh failed.');
1147
- }
1148
- return [];
1352
+ throw error instanceof Error ? error : new Error('AirPlay receiver refresh failed.');
1149
1353
  }
1150
- }, [player]);
1354
+ }, [airPlaySupported, airPlayUnavailableMessage, platform, player, showTransientMessage]);
1151
1355
  const openAirPlaySettings = useCallback(() => {
1356
+ if (!airPlaySupported) {
1357
+ showTransientMessage(airPlayUnavailableMessage);
1358
+ return;
1359
+ }
1152
1360
  const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
1153
1361
  selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
1154
1362
  go('airplay-settings', { resetSelection: false });
1155
- void refreshAirPlayTargets(false);
1156
- }, [availableAirPlayDevices, go, refreshAirPlayTargets]);
1363
+ void refreshAirPlayTargets(false).catch(reportActionError);
1364
+ }, [airPlaySupported, airPlayUnavailableMessage, availableAirPlayDevices, go, refreshAirPlayTargets, reportActionError, showTransientMessage]);
1157
1365
  const selectAirPlayDeviceAt = useCallback((index) => {
1158
- const device = availableAirPlayDevices[index];
1159
- if (!device) {
1160
- setMessage('No AirPlay receiver selected.');
1161
- return;
1162
- }
1163
- updateSettings({ preferredAirPlayDevice: device.id });
1164
- selectedByScreenRef.current['airplay-settings'] = index;
1165
- if (device.local) {
1166
- const preferredBackend = preferredLocalPlaybackBackend(availableBackends);
1366
+ pendingOutputChangesRef.current += 1;
1367
+ void (async () => {
1368
+ const requestId = outputCancellationRef.current;
1369
+ const device = availableAirPlayDevices[index];
1370
+ if (!device) {
1371
+ setMessage('No AirPlay receiver selected.');
1372
+ return;
1373
+ }
1374
+ const preferredBackend = device.local
1375
+ ? preferredLocalPlaybackBackend(availableBackends)
1376
+ : isAirPlayBackendAvailable(availableBackends) ? 'airplay' : null;
1377
+ await updateSettings({ preferredAirPlayDevice: device.id, ...(preferredBackend ? { preferredBackend } : {}) });
1378
+ if (requestId !== outputCancellationRef.current)
1379
+ return;
1380
+ selectedByScreenRef.current['airplay-settings'] = index;
1381
+ if (device.local) {
1382
+ if (!preferredBackend) {
1383
+ setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`);
1384
+ return;
1385
+ }
1386
+ if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
1387
+ const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
1388
+ setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Switching audio to ${audioOutputLabel(preferredBackend)}...`);
1389
+ await playStation(playingStation, { queue, outputChange: true });
1390
+ return;
1391
+ }
1392
+ setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Audio output: ${audioOutputLabel(preferredBackend)}.`);
1393
+ return;
1394
+ }
1167
1395
  if (!preferredBackend) {
1168
- setMessage(`${device.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
1396
+ setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
1169
1397
  return;
1170
1398
  }
1171
- updateSettings({ preferredBackend });
1172
1399
  if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
1173
1400
  const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
1174
- setMessage(`${device.name} is this Mac. Switching audio to ${audioOutputLabel(preferredBackend)}...`);
1175
- void playStation(playingStation, { queue });
1401
+ setMessage(`Switching audio to AirPlay: ${device.name}...`);
1402
+ await playStation(playingStation, { queue, outputChange: true });
1176
1403
  return;
1177
1404
  }
1178
- setMessage(`${device.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
1179
- return;
1180
- }
1181
- if (!isAirPlayBackendAvailable(availableBackends)) {
1182
- setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
1183
- return;
1184
- }
1185
- updateSettings({ preferredBackend: 'airplay' });
1186
- if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
1187
- const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
1188
- setMessage(`Switching audio to AirPlay: ${device.name}...`);
1189
- void playStation(playingStation, { queue });
1190
- return;
1191
- }
1192
- setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`);
1193
- }, [availableAirPlayDevices, availableBackends, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings]);
1405
+ setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`);
1406
+ })().catch(reportActionError).finally(() => { pendingOutputChangesRef.current -= 1; });
1407
+ }, [availableAirPlayDevices, availableBackends, platform, playback.state, player, playStation, playingStation, queueFromCurrentList, reportActionError, updateSettings]);
1194
1408
  const cycleAudioOutput = useCallback(() => {
1195
- const currentOutput = settingsRef.current.preferredBackend;
1196
- const activeNeedsSelectedOutput = Boolean(playingStation &&
1197
- shouldRetuneForAudioOutput(playback.state) &&
1198
- audioOutputCanApply(currentOutput, settingsRef.current) &&
1199
- audioOutputNeedsActiveSwitch(currentOutput, playback.backend, availableBackends));
1200
- const preferredBackend = activeNeedsSelectedOutput
1201
- ? currentOutput
1202
- : nextAvailablePlaybackBackend(currentOutput, availableBackends);
1203
- updateSettings({ preferredBackend });
1204
- if (preferredBackend === 'airplay' && !settingsRef.current.preferredAirPlayDevice) {
1205
- setMessage('Choose an AirPlay receiver first. Your current station will keep playing until you pick one.');
1206
- openAirPlaySettings();
1207
- return;
1208
- }
1209
- if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
1210
- const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
1211
- setMessage(`Switching audio to ${audioOutputSwitchLabel(preferredBackend, availableBackends)}...`);
1212
- void playStation(playingStation, { queue });
1213
- return;
1214
- }
1215
- setMessage(`Audio output: ${audioOutputSwitchLabel(preferredBackend, availableBackends)}.`);
1216
- }, [availableBackends, openAirPlaySettings, playback.backend, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings]);
1409
+ const precedingOutputChange = pendingOutputChangesRef.current > 0;
1410
+ pendingOutputChangesRef.current += 1;
1411
+ void (async () => {
1412
+ const requestId = outputCancellationRef.current;
1413
+ let preferredBackend;
1414
+ await updateSettings(current => {
1415
+ const currentPlayback = player.getState();
1416
+ const activeNeedsSelectedOutput = Boolean(!precedingOutputChange && playingStation &&
1417
+ shouldRetuneForAudioOutput(currentPlayback.state) &&
1418
+ audioOutputCanApply(current.preferredBackend, current) &&
1419
+ audioOutputNeedsActiveSwitch(current.preferredBackend, currentPlayback.backend, availableBackends));
1420
+ preferredBackend = activeNeedsSelectedOutput
1421
+ ? current.preferredBackend
1422
+ : nextAvailablePlaybackBackend(current.preferredBackend, platform === 'linux' && airPlaySupported ? [...new Set([...availableBackends, 'airplay'])] : availableBackends);
1423
+ return { preferredBackend };
1424
+ });
1425
+ if (requestId !== outputCancellationRef.current)
1426
+ return;
1427
+ if (preferredBackend === 'airplay' && (!settingsRef.current.preferredAirPlayDevice || player.airPlaySelectionRequired())) {
1428
+ setMessage('Choose an AirPlay receiver first. Your current station will keep playing until you pick one.');
1429
+ openAirPlaySettings();
1430
+ return;
1431
+ }
1432
+ if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
1433
+ const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
1434
+ setMessage(`Switching audio to ${audioOutputSwitchLabel(preferredBackend, availableBackends)}...`);
1435
+ await playStation(playingStation, { queue, outputChange: true });
1436
+ return;
1437
+ }
1438
+ setMessage(`Audio output: ${audioOutputSwitchLabel(preferredBackend, availableBackends)}.`);
1439
+ })().catch(reportActionError).finally(() => { pendingOutputChangesRef.current -= 1; });
1440
+ }, [airPlaySupported, availableBackends, openAirPlaySettings, platform, playback.state, player, playStation, playingStation, queueFromCurrentList, reportActionError, updateSettings]);
1217
1441
  const toggleSkipBrokenStreams = useCallback(() => {
1218
- const skipBrokenStreams = !settingsRef.current.skipBrokenStreams;
1219
- updateSettings({ skipBrokenStreams });
1220
- setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
1442
+ void updateSettings(current => ({ skipBrokenStreams: !current.skipBrokenStreams }))
1443
+ .then(saved => setMessage(`Skip broken streams ${saved.settings.skipBrokenStreams ? 'enabled' : 'disabled'}.`))
1444
+ .catch(() => undefined);
1221
1445
  }, [updateSettings]);
1222
1446
  const toggleSetting = useCallback((key) => {
1223
- const current = key === 'mouseSupport'
1224
- ? settingsRef.current.mouseSupport !== false
1225
- : Boolean(settingsRef.current[key]);
1226
- const next = !current;
1227
- updateSettings({ [key]: next });
1228
- setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
1447
+ void updateSettings(current => ({ [key]: key === 'mouseSupport' ? current.mouseSupport === false : !current[key] }))
1448
+ .then(saved => setMessage(`${settingToggleLabel[key]} ${saved.settings[key] ? 'on' : 'off'}.`))
1449
+ .catch(() => undefined);
1229
1450
  }, [updateSettings]);
1230
1451
  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'}.`);
1452
+ void updateSettings(current => {
1453
+ const agentControl = current.agentControl ?? defaultAgentControlSettings;
1454
+ return { agentControl: { ...agentControl, [key]: !agentControl[key] } };
1455
+ }).then(saved => setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${saved.settings.agentControl?.[key] ? 'on' : 'off'}.`))
1456
+ .catch(() => undefined);
1235
1457
  }, [updateSettings]);
1236
1458
  const setAgentIntegrationEnabled = useCallback(async () => {
1237
1459
  if (configuringMcpRef.current) {
@@ -1239,19 +1461,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1239
1461
  return;
1240
1462
  }
1241
1463
  configuringMcpRef.current = true;
1464
+ await settingsQueueRef.current;
1242
1465
  const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
1243
1466
  setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
1244
1467
  try {
1245
1468
  const results = await mcpConfigurator(enabled, mcpRuntime(), null);
1246
1469
  const failed = results.filter(item => item.status === 'failed');
1247
- const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1248
- updateSettings({ agentControl: { ...agentControl, enabled } });
1470
+ await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled } }));
1249
1471
  setMessage(integrationResultMessage(enabled, results, failed));
1250
1472
  }
1251
1473
  catch (error) {
1252
1474
  if (!enabled) {
1253
- const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1254
- updateSettings({ agentControl: { ...agentControl, enabled: false } });
1475
+ await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: false } }));
1255
1476
  }
1256
1477
  setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
1257
1478
  }
@@ -1269,8 +1490,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1269
1490
  try {
1270
1491
  const results = await mcpConfigurator(true, mcpRuntime(), null);
1271
1492
  const failed = results.filter(item => item.status === 'failed');
1272
- const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1273
- updateSettings({ agentControl: { ...agentControl, enabled: true } });
1493
+ await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: true } }));
1274
1494
  setMessage(integrationResultMessage(true, results, failed));
1275
1495
  }
1276
1496
  catch (error) {
@@ -1284,42 +1504,44 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1284
1504
  const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
1285
1505
  const next = nextSleepTimerMinutes(currentMinutes);
1286
1506
  setSleepUntil(next ? Date.now() + next * 60_000 : null);
1287
- }, [sleepUntil]);
1507
+ }, [sleepUntil, setSleepUntil]);
1288
1508
  const openLibrary = useCallback(() => {
1289
1509
  go('library', { resetSelection: false });
1290
1510
  }, [go]);
1291
1511
  const selectedStation = displayStations[selected] ?? null;
1292
- const selectedStationForInput = useCallback(() => displayStationsRef.current[selectedRef.current] ?? null, []);
1512
+ const selectedStationForInput = useCallback(() => {
1513
+ const key = stationContextKeyForScreen(screenRef.current);
1514
+ const stations = key ? applyStationFilters(stationContextsRef.current[key].stations, filters) : displayStationsRef.current;
1515
+ return stations[selectedRef.current] ?? null;
1516
+ }, [filters]);
1293
1517
  const alarmTui = useAlarmTui({
1294
1518
  service: alarmService,
1295
1519
  store,
1296
1520
  library,
1297
1521
  setLibrary,
1298
1522
  screen,
1523
+ screenRef,
1299
1524
  selected,
1525
+ selectedRef,
1300
1526
  setSelected,
1301
1527
  go,
1302
1528
  setMessage,
1303
1529
  playingStation,
1304
- previewStation: alarmPreview ?? (station => playStation(station))
1530
+ previewStation: alarmPreview ?? (async (station) => { await playStation(station); })
1305
1531
  });
1306
1532
  for (const alarmScreen of ['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']) {
1307
1533
  itemCountsRef.current[alarmScreen] = alarmTui.itemCount(alarmScreen) ?? 0;
1308
1534
  }
1309
1535
  const openScreen = useCallback((target) => {
1310
1536
  if (target === 'explore') {
1311
- if (stationContextsRef.current.explore.stations.length > 0) {
1312
- go('explore');
1313
- }
1314
- else {
1537
+ go('explore');
1538
+ if (stationContextsRef.current.explore.stations.length === 0 && !loadingStationContextsRef.current.explore) {
1315
1539
  void loadExplore();
1316
1540
  }
1317
1541
  }
1318
1542
  else if (target === 'nearby') {
1319
- if (stationContextsRef.current.nearby.stations.length > 0) {
1320
- go('nearby');
1321
- }
1322
- else {
1543
+ go('nearby');
1544
+ if (stationContextsRef.current.nearby.stations.length === 0 && !loadingStationContextsRef.current.nearby) {
1323
1545
  void loadNearby();
1324
1546
  }
1325
1547
  }
@@ -1338,14 +1560,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1338
1560
  }
1339
1561
  }, [go, loadExplore, loadNearby, openAirPlaySettings, openLibrary]);
1340
1562
  const openAdjacentTab = useCallback((direction) => {
1341
- const active = activeTabForScreen(screen);
1563
+ const active = activeTabForScreen(screenRef.current);
1342
1564
  const currentIndex = topTabs.findIndex(tab => tab.screen === active);
1343
1565
  const nextIndex = (currentIndex + direction + topTabs.length) % topTabs.length;
1344
1566
  const next = topTabs[nextIndex];
1345
1567
  if (next) {
1346
1568
  openScreen(next.screen);
1347
1569
  }
1348
- }, [openScreen, screen]);
1570
+ }, [openScreen]);
1349
1571
  const handleUpdateCommand = useCallback(async () => {
1350
1572
  const updateCheck = library.updateCheck;
1351
1573
  if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
@@ -1357,7 +1579,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1357
1579
  }
1358
1580
  }
1359
1581
  const command = updateCommandForInstall();
1360
- const copied = copyToClipboard(command.command);
1582
+ const copied = await copyToClipboard(command.command);
1361
1583
  const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
1362
1584
  const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
1363
1585
  const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
@@ -1394,14 +1616,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1394
1616
  try {
1395
1617
  const repaired = await mcpConfigurator(true, mcpRuntime(), null);
1396
1618
  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.`);
1619
+ setMessage(`Update installed.${suffix} Quit and reopen RadioCLI; restart MCP clients to load updated tools.`);
1398
1620
  }
1399
1621
  catch {
1400
- setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then restart RadioCLI and open agent clients.');
1622
+ setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then quit and reopen RadioCLI and restart MCP clients.');
1401
1623
  }
1402
1624
  }
1403
1625
  else {
1404
- setMessage('Update installed. Restart RadioCLI to use the new version.');
1626
+ setMessage('Update installed. Quit and reopen RadioCLI to use the new version.');
1405
1627
  }
1406
1628
  return;
1407
1629
  }
@@ -1410,17 +1632,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1410
1632
  }, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
1411
1633
  const executeCommand = useCommandExecutor({
1412
1634
  beginLearningTransportKey,
1635
+ cancelPendingTune,
1413
1636
  countries,
1414
1637
  go,
1415
1638
  loadCountry,
1416
1639
  openAirPlaySettings,
1640
+ openAirPlayCode,
1417
1641
  openLibrary,
1642
+ persistLibrary,
1418
1643
  player,
1419
1644
  playingStation,
1420
1645
  providers,
1421
1646
  resetLearnedTransportKeys,
1422
1647
  runSearch,
1423
1648
  screen,
1649
+ screenRef,
1424
1650
  selectedStation,
1425
1651
  setCountries,
1426
1652
  setFilters,
@@ -1459,11 +1685,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1459
1685
  void playStation(nextStation, { queue });
1460
1686
  }
1461
1687
  }, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
1462
- agentHandlerRef.current = async (command) => {
1688
+ agentHandlerRef.current = async (command, signal) => {
1463
1689
  const sessionStatus = () => ({
1464
1690
  owner: 'tui',
1465
1691
  playback: player.getState(),
1466
1692
  station: playingStationRef.current,
1693
+ nowPlaying: engine.snapshot().nowPlaying,
1694
+ persistenceWarning: persistenceWarning ?? engine.snapshot().warning,
1467
1695
  queue: playbackQueueRef.current?.stations ?? [],
1468
1696
  output: {
1469
1697
  preferredBackend: settingsRef.current.preferredBackend,
@@ -1471,6 +1699,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1471
1699
  }
1472
1700
  });
1473
1701
  const respond = (message, ok = true, data) => ({ ok, message, status: sessionStatus(), ...(data ? { data } : {}) });
1702
+ if (command.type === 'desktop-transport') {
1703
+ const resolved = resolveDesktopTransport(command.action, sessionStatus());
1704
+ return resolved ? agentHandlerRef.current(resolved, signal) : respond('Playback state unchanged.');
1705
+ }
1706
+ if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) {
1707
+ return respond(airPlayUnavailableMessage, false, command.type === 'airplay-list' ? [] : undefined);
1708
+ }
1474
1709
  if (command.type === 'status')
1475
1710
  return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
1476
1711
  if (command.type === 'play') {
@@ -1478,28 +1713,37 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1478
1713
  return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
1479
1714
  }
1480
1715
  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 });
1716
+ const tuned = await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying, signal });
1482
1717
  const state = player.getState();
1483
- const playingRequestedStation = playingStationRef.current
1484
- && stationKey(playingStationRef.current) === stationKey(command.station)
1718
+ const playingRequestedStation = tuned && playingStationRef.current
1719
+ && stationKey(playingStationRef.current) === stationKey(tuned)
1485
1720
  && state.state === 'playing'
1486
1721
  && state.ready;
1487
1722
  return playingRequestedStation
1488
- ? respond(`Playing ${command.station.name}.`)
1723
+ ? respond(`Playing ${tuned.name}.`)
1489
1724
  : respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
1490
1725
  }
1491
1726
  if (command.type === 'pause') {
1492
- const control = await player.pause();
1727
+ const control = await engine.pause();
1493
1728
  return respond(control.message ?? 'Paused.', control.ok);
1494
1729
  }
1495
1730
  if (command.type === 'resume') {
1496
- const control = await player.resume();
1731
+ const station = playingStationRef.current;
1732
+ if (command.restartStopped && station && ['idle', 'stopped'].includes(player.getState().state)) {
1733
+ await playStation(station, { queue: playbackQueueRef.current ?? queueFromCurrentList(station), signal });
1734
+ return respond(`Playing ${station.name}.`, player.getState().ready && player.getState().state === 'playing');
1735
+ }
1736
+ const control = await engine.resume();
1497
1737
  return respond(control.message ?? 'Resumed.', control.ok);
1498
1738
  }
1499
1739
  if (command.type === 'stop') {
1500
- tuneRequestRef.current += 1;
1501
- setLibrary(store.finishActiveListeningSession());
1502
- await player.stop();
1740
+ cancelPendingTune();
1741
+ await engine.stop({ retainSelection: command.retainSelection });
1742
+ if (command.retainSelection) {
1743
+ setTuningStation(null);
1744
+ desktopMediaRef.current?.refresh();
1745
+ return respond('Stopped playback.');
1746
+ }
1503
1747
  playingStationRef.current = null;
1504
1748
  setPlayingStation(null);
1505
1749
  setTuningStation(null);
@@ -1507,9 +1751,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1507
1751
  return respond('Stopped RadioCLI.');
1508
1752
  }
1509
1753
  if (command.type === 'alarm-preempt') {
1510
- tuneRequestRef.current += 1;
1511
- setLibrary(store.finishActiveListeningSession());
1512
- await player.stop();
1754
+ desktopSuspendedRef.current = true;
1755
+ desktopMediaRef.current?.refresh();
1756
+ cancelPendingTune();
1757
+ await engine.stop();
1513
1758
  playingStationRef.current = null;
1514
1759
  setPlayingStation(null);
1515
1760
  setTuningStation(null);
@@ -1517,9 +1762,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1517
1762
  return respond('Interactive playback yielded to the alarm.');
1518
1763
  }
1519
1764
  if (command.type === 'set-volume') {
1520
- const control = await player.setVolume(clampVolume(command.volume));
1765
+ const control = command.unmute
1766
+ ? await player.setVolume(clampVolume(command.volume), { allowStopped: true })
1767
+ : await player.setVolume(clampVolume(command.volume));
1521
1768
  if (control.ok)
1522
- updateSettings({ volume: player.getState().volume });
1769
+ await updateSettings({ volume: player.getState().volume });
1770
+ if (control.ok && command.unmute && command.volume > 0) {
1771
+ const unmuted = await player.setMuted(false, { allowStopped: true });
1772
+ if (!unmuted.ok)
1773
+ return respond(unmuted.message ?? 'The output could not be unmuted.', false);
1774
+ }
1523
1775
  return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
1524
1776
  }
1525
1777
  if (command.type === 'set-muted') {
@@ -1532,15 +1784,36 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1532
1784
  return respond('No active station to favorite.', false);
1533
1785
  const current = store.isFavorite(station);
1534
1786
  if (current !== command.favorite)
1535
- toggleFavorite(station);
1787
+ await toggleFavorite(station);
1536
1788
  return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
1537
1789
  }
1538
1790
  if (command.type === 'airplay-list') {
1539
- const devices = await refreshAirPlayTargets(false);
1791
+ if (networkPolicy().offline)
1792
+ return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
1793
+ let devices;
1794
+ try {
1795
+ devices = await refreshAirPlayTargets(false);
1796
+ }
1797
+ catch (error) {
1798
+ if (error instanceof PlaybackOutputError)
1799
+ return respond(error.message, false, []);
1800
+ throw error;
1801
+ }
1540
1802
  return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
1541
1803
  }
1542
1804
  if (command.type === 'airplay-select') {
1543
- const devices = await refreshAirPlayTargets(false);
1805
+ if (networkPolicy().offline)
1806
+ return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false);
1807
+ let devices;
1808
+ try {
1809
+ devices = await refreshAirPlayTargets(false);
1810
+ }
1811
+ catch (error) {
1812
+ if (error instanceof PlaybackOutputError)
1813
+ return respond(error.message, false, []);
1814
+ throw error;
1815
+ }
1816
+ signal?.throwIfAborted();
1544
1817
  const device = devices.find(item => item.id === command.deviceId);
1545
1818
  if (!device)
1546
1819
  return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
@@ -1548,18 +1821,19 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1548
1821
  const backend = preferredLocalPlaybackBackend(availableBackends);
1549
1822
  if (!backend)
1550
1823
  return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
1551
- updateSettings({ preferredBackend: backend });
1824
+ await updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: backend });
1825
+ signal?.throwIfAborted();
1552
1826
  if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1553
- await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1827
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
1554
1828
  }
1555
1829
  return respond(`Audio output set to this device (${backend}).`);
1556
1830
  }
1557
1831
  if (!isAirPlayBackendAvailable(availableBackends))
1558
1832
  return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
1559
- updateSettings({ preferredAirPlayDevice: device.id });
1560
- updateSettings({ preferredBackend: 'airplay' });
1833
+ await updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: 'airplay' });
1834
+ signal?.throwIfAborted();
1561
1835
  if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1562
- await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1836
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
1563
1837
  }
1564
1838
  return respond(`Audio output set to AirPlay receiver ${device.name}.`);
1565
1839
  }
@@ -1567,20 +1841,26 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1567
1841
  const backend = preferredLocalPlaybackBackend(availableBackends);
1568
1842
  if (!backend)
1569
1843
  return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
1570
- updateSettings({ preferredBackend: backend });
1844
+ await updateSettings({ preferredBackend: backend });
1845
+ signal?.throwIfAborted();
1571
1846
  if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1572
- await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1847
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
1573
1848
  }
1574
1849
  return respond(`Audio output set to this device (${backend}).`);
1575
1850
  }
1576
1851
  if (command.type === 'airplay-passcode') {
1577
- const control = player.submitAirPlayPasscode(command.code);
1852
+ const control = await player.submitAirPlayPasscode(command.code);
1578
1853
  return respond(control.message ?? 'AirPlay code sent.', control.ok);
1579
1854
  }
1580
1855
  if (command.type === 'update-settings') {
1581
- updateSettings(command.settings);
1856
+ if (!allowedRadioSessionSettings(command.settings))
1857
+ return respond('Session settings only accept appearance and agent control. Use radiocli setup --airplay to enable Linux AirPlay.', false);
1858
+ await updateSettings(command.settings);
1582
1859
  return respond('RadioCLI settings updated.');
1583
1860
  }
1861
+ if (command.type !== 'next' && command.type !== 'previous')
1862
+ throw new Error('Unsupported radio command.');
1863
+ signal?.throwIfAborted();
1584
1864
  const active = playingStationRef.current;
1585
1865
  const queue = playbackQueueRef.current;
1586
1866
  if (!active || !queue?.stations.length)
@@ -1590,33 +1870,80 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1590
1870
  const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
1591
1871
  if (!next)
1592
1872
  return respond('No adjacent station is available.', false);
1593
- await playStation(next, { queue });
1594
- return respond(`Playing ${next.name}.`);
1873
+ if (command.preserveStop && ['idle', 'stopped'].includes(player.getState().state)) {
1874
+ engine.select(next, queue.stations);
1875
+ playingStationRef.current = next;
1876
+ setPlayingStation(next);
1877
+ setNowPlaying(null);
1878
+ desktopMediaRef.current?.refresh();
1879
+ return respond(`Selected ${next.name}.`);
1880
+ }
1881
+ const startPaused = command.preservePause && player.getState().state === 'paused';
1882
+ await playStation(next, { queue, startPaused, signal });
1883
+ const state = player.getState();
1884
+ const ok = playingStationRef.current && stationKey(playingStationRef.current) === stationKey(next) && state.ready && ['playing', 'paused'].includes(state.state);
1885
+ return respond(`${startPaused ? 'Selected' : 'Playing'} ${next.name}.`, Boolean(ok));
1595
1886
  };
1887
+ activeAlarmRef.current = alarmTui.activeAlarms.length > 0;
1888
+ useEffect(() => {
1889
+ if (playbackSession || !stdin.isTTY || !desktopPolicy.enabled || !controlSessionReady)
1890
+ return;
1891
+ const media = startDesktopMediaControls({
1892
+ player, identity: store.filePath, policy: desktopPolicy,
1893
+ status: () => ({ owner: 'tui', playback: player.getState(), station: playingStationRef.current, queue: playbackQueueRef.current?.stations ?? [] }),
1894
+ command: (command, permitted) => dispatchOwnerCommand(command, () => permitted?.() !== false && !desktopSuspendedRef.current && !activeAlarmRef.current),
1895
+ suspended: () => desktopSuspendedRef.current || activeAlarmRef.current
1896
+ });
1897
+ desktopMediaRef.current = media;
1898
+ return () => { media.close(); if (desktopMediaRef.current === media)
1899
+ desktopMediaRef.current = null; };
1900
+ }, [controlSessionReady, desktopPolicy, dispatchOwnerCommand, player, stdin, store, playbackSession]);
1901
+ useEffect(() => { desktopMediaRef.current?.refresh(); }, [alarmTui.activeAlarms.length]);
1596
1902
  useEffect(() => {
1597
- if (!stdin.isTTY || !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
1903
+ if (!stdin.isTTY)
1598
1904
  return;
1905
+ if (playbackSession) {
1906
+ if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled) {
1907
+ void playbackSession.call(initialAgentCommand).catch(reportActionError);
1908
+ }
1909
+ return;
1910
+ }
1599
1911
  let disposed = false;
1600
1912
  let close;
1601
- void startRadioSession(command => agentHandlerRef.current(command)).then(async (session) => {
1913
+ void startRadioSession(async (command, source) => {
1914
+ const permitted = async () => {
1915
+ if (source !== 'cli' && command.type !== 'status' && command.type !== 'alarm-preempt')
1916
+ await store.readFresh();
1917
+ if (disposed)
1918
+ throw new Error('RadioCLI control session is closed.');
1919
+ if (source !== 'cli' && !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled && command.type !== 'status' && command.type !== 'alarm-preempt') {
1920
+ throw new Error('Agent controls are disabled.');
1921
+ }
1922
+ return true;
1923
+ };
1924
+ return dispatchOwnerCommand(command, permitted);
1925
+ }, undefined, { queue: 'owner' }).then(async (session) => {
1602
1926
  if (disposed) {
1603
1927
  await session.close();
1604
1928
  return;
1605
1929
  }
1606
1930
  close = session.close;
1607
- if (initialAgentCommand)
1608
- await agentHandlerRef.current(initialAgentCommand);
1931
+ setControlSessionReady(true);
1932
+ if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
1933
+ await dispatchOwnerCommand(initialAgentCommand);
1609
1934
  }).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
1610
1935
  return () => {
1611
1936
  disposed = true;
1937
+ setControlSessionReady(false);
1612
1938
  if (close)
1613
1939
  void close();
1614
1940
  };
1615
- }, [initialAgentCommand, library.settings.agentControl?.enabled, stdin]);
1941
+ }, [dispatchOwnerCommand, initialAgentCommand, stdin, store, playbackSession, reportActionError]);
1616
1942
  useAppInput({
1617
1943
  adjustVolume,
1618
1944
  airPlayCode,
1619
1945
  canEnterAirPlayCode,
1946
+ airPlaySupported,
1620
1947
  cancelPendingAutoSkip,
1621
1948
  copyStationUrl,
1622
1949
  openStationHomepage,
@@ -1656,7 +1983,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1656
1983
  moveExploreCursorToCell: moveExploreMapCursorToCell,
1657
1984
  refreshProviderHealth,
1658
1985
  refreshAirPlayTargets: () => {
1659
- void refreshAirPlayTargets();
1986
+ void refreshAirPlayTargets().catch(reportActionError);
1660
1987
  },
1661
1988
  resetLearnedTransportKeys,
1662
1989
  repairMcpIntegrations,
@@ -1664,7 +1991,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1664
1991
  runSearch,
1665
1992
  saveLearnedTransportKey,
1666
1993
  screen,
1667
- settingsPage,
1994
+ screenRef,
1995
+ settingsPageRef,
1668
1996
  searchQuery,
1669
1997
  selectedRef,
1670
1998
  selectedStationForInput,
@@ -1711,6 +2039,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1711
2039
  spinnerFrame
1712
2040
  });
1713
2041
  const basePageFooter = pageFooterText({
2042
+ airPlaySupported,
1714
2043
  canEnterAirPlayCode,
1715
2044
  capturingTransportAction,
1716
2045
  commandMode,
@@ -1730,7 +2059,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1730
2059
  (screen === 'search' && editingSearch) ||
1731
2060
  screen === 'airplay-code');
1732
2061
  const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
1733
- const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
2062
+ const statusMessage = message ?? persistenceWarning ?? presenceWarning ?? (screen === 'search' ? stationContext.error ?? null : null);
2063
+ const microFooter = footerMessage ?? statusMessage ?? (pageFooterOwnsCompactRow
1734
2064
  ? pageFooter
1735
2065
  : footerPlayback.state === 'loading' && playbackFooter
1736
2066
  ? playbackFooter
@@ -1739,17 +2069,17 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1739
2069
  : pageFooter);
1740
2070
  const compactGlobalFooter = '←/→ tabs · ? help · q quit';
1741
2071
  const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
1742
- const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
1743
- const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
1744
- const compactStatus = footerMessage ?? message ?? playbackFooter;
2072
+ const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter);
2073
+ const fullLegendRows = useMemo(() => balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve), [pageFooter, globalFooter, frameWidth, versionReserve]);
2074
+ const compactStatus = footerMessage ?? statusMessage ?? playbackFooter;
1745
2075
  const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
1746
- const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
2076
+ const compactLegendRows = useMemo(() => balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve), [pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve]);
1747
2077
  const microLegendWidth = Math.max(1, frameWidth - versionReserve);
1748
- const microLegend = commandMode || capturingTransportAction || footerMessage || message
2078
+ const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage
1749
2079
  ? microFooter
1750
2080
  : microShortcutFooterText(microFooter, microLegendWidth);
1751
2081
  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}`))] }) })) })] }) }) }));
2082
+ 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, { platform: platform, 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}`))] }) })) })] }) }) }));
1753
2083
  }
1754
2084
  function mcpRuntime() {
1755
2085
  return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
@@ -1777,12 +2107,12 @@ function buildLibraryStations(library) {
1777
2107
  for (const station of library.favorites) {
1778
2108
  addStation(station);
1779
2109
  }
1780
- for (const item of library.recent) {
1781
- addStation(item.station);
1782
- }
1783
2110
  for (const station of library.imported) {
1784
2111
  addStation(station);
1785
2112
  }
2113
+ for (const item of library.recent) {
2114
+ addStation(item.station);
2115
+ }
1786
2116
  return stations;
1787
2117
  }
1788
2118
  function exploreCursorLocation(cursor) {
@@ -1804,6 +2134,10 @@ function appendUniqueStations(current, page) {
1804
2134
  }
1805
2135
  return stations;
1806
2136
  }
2137
+ function discoverySourceWarning(sources) {
2138
+ const unavailable = [...new Set(sources.filter(source => source.status === 'unavailable').map(source => source.provider === 'radio-browser' ? 'Radio Browser' : source.provider === 'radio-garden' ? 'Radio Garden' : source.provider))];
2139
+ return unavailable.length ? `Some sources are unavailable (${unavailable.join(', ')}). Showing available results.` : undefined;
2140
+ }
1807
2141
  function formatCountryStationsSubtitle(country, loaded, hasMore) {
1808
2142
  const total = country.stationCount.toLocaleString();
1809
2143
  const loadedLabel = loaded.toLocaleString();
@@ -1880,7 +2214,7 @@ function audioOutputSwitchLabel(output, backends) {
1880
2214
  return audioOutputLabel(output);
1881
2215
  }
1882
2216
  function librarySubtitle(library) {
1883
- return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
2217
+ return `${library.favorites.length} favorites · ${library.imported.length} imported · ${library.recent.length} recent · new imports before recents`;
1884
2218
  }
1885
2219
  function mouseVisibleRows(screen, layout) {
1886
2220
  if (screen === 'help')
@@ -1907,3 +2241,10 @@ function mouseVisibleRows(screen, layout) {
1907
2241
  return layout.stationRows;
1908
2242
  return layout.contentRows;
1909
2243
  }
2244
+ function localSessionLibrary(library, platform, selectionRequired = true) {
2245
+ return platform === 'linux' && selectionRequired && library.settings.preferredBackend === 'airplay'
2246
+ ? { ...library, settings: { ...library.settings, preferredBackend: 'auto' } } : library;
2247
+ }
2248
+ function legacyDiscoveryPage(items, limit) {
2249
+ return { items, exhausted: items.length < limit, sources: [] };
2250
+ }