@ciphore/radiocli 0.2.4 → 0.2.6

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