@ciphore/radiocli 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,16 +1,20 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, unlinkSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
3
  import { dirname, join } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
- import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
7
- import { resolveCommand } from './command.js';
5
+ import { airPlayLocalDeviceLabel, detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
6
+ import { resolveCommand } from '../platform/executables.js';
7
+ import { airPlayPolicy, airPlaySetupRequiredMessage } from './airplay-policy.js';
8
8
  import { discoverAirPlayDevices } from './airplay-discovery.js';
9
9
  import { airPlaySenderHealth } from './airplay-sender-health.js';
10
10
  import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
11
11
  import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
12
12
  import { MpvIpcClient } from './mpv-ipc-client.js';
13
+ import { mpvIpcPath } from '../platform/ipc.js';
14
+ import { desktopMediaPolicy } from '../platform/desktop-media.js';
15
+ import { canRetryPlainHttp, ffmpegCertificateFailure, ffmpegInputArguments, ffmpegInputFailure } from './ffmpeg-input.js';
13
16
  const minAirPlayTuneTimeoutSeconds = 30;
17
+ const maxPlayerDiagnosticCharacters = 4096;
14
18
  export class PlaybackOutputError extends Error {
15
19
  constructor(message) {
16
20
  super(message);
@@ -22,6 +26,7 @@ export function isPlaybackOutputError(error) {
22
26
  }
23
27
  export class PlayerController {
24
28
  getSettings;
29
+ runtime;
25
30
  process = null;
26
31
  backend = null;
27
32
  ipcPath = null;
@@ -42,17 +47,32 @@ export class PlayerController {
42
47
  airPlayPasscodes = new Map();
43
48
  currentAirPlayDevice = null;
44
49
  currentAirPlayDeviceId = null;
50
+ selectedAirPlayDeviceId = null;
45
51
  pendingAirPlayPasscode = null;
46
52
  currentMpvMediaTitle = null;
47
53
  metadataPollInFlight = false;
48
54
  playbackStatePollInFlight = false;
55
+ mpvLiveRetuneInFlight = false;
56
+ mpvRetuneFailure;
49
57
  pendingMpvVolume = null;
50
58
  mpvVolumeFlush = null;
51
59
  confirmedMpvVolume = 70;
52
60
  mpvSessionId = 0;
61
+ playbackSessionId = 0;
53
62
  stopPromise = null;
54
- constructor(getSettings) {
63
+ lastPlayerExit = null;
64
+ activePlayerDiagnostic = '';
65
+ compatibleDesktopMedia = true;
66
+ playRequestId = 0;
67
+ mpvObserved = false;
68
+ stationIsRadio = false;
69
+ mpvMediaIsLive = false;
70
+ mpvProgress = null;
71
+ mpvStallTimer;
72
+ fallbackAudioStarted = false;
73
+ constructor(getSettings, runtime = { platform: process.platform, arch: process.arch, env: process.env }) {
55
74
  this.getSettings = getSettings;
75
+ this.runtime = runtime;
56
76
  }
57
77
  onChange(listener) {
58
78
  this.listeners.add(listener);
@@ -66,11 +86,24 @@ export class PlayerController {
66
86
  getState() {
67
87
  return { ...this.state };
68
88
  }
89
+ desktopMediaCompatible() { return this.compatibleDesktopMedia; }
90
+ /** Retry diagnosed transport failures and interrupted live radio, never
91
+ * completed recordings, authentication or device/decoder initialization. */
92
+ recoverableFailure() {
93
+ const exit = this.lastPlayerExit;
94
+ if (!exit || exit.spawnError || exit.signal || exit.code === 0 || !/^https?:\/\//i.test(this.state.streamUrl ?? ''))
95
+ return false;
96
+ const detail = `${exit.diagnostic} ${this.state.message ?? ''}`;
97
+ if (ffmpegCertificateFailure(detail) || ffmpegInputFailure(detail))
98
+ return false;
99
+ return (exit.transportFailure === true || /timed? out|connection (?:reset|refused|closed)|network (?:is )?unreachable|temporary failure|unexpected (?:end|eof)|http (?:error )?50[234]/i.test(detail))
100
+ && !/password|passcode|authentication|unauthori[sz]ed|forbidden|certificate|tls.*verif|audio output|audio device|decoder|unknown option/i.test(detail);
101
+ }
69
102
  diagnostics() {
70
103
  return {
71
104
  backend: this.state.backend,
72
105
  availableBackends: this.detectedBackends(),
73
- preferredBackend: this.getSettings().preferredBackend,
106
+ preferredBackend: this.effectivePreferredBackend(),
74
107
  active: Boolean(this.process),
75
108
  streamUrl: this.state.streamUrl,
76
109
  stationName: this.state.stationName,
@@ -81,43 +114,73 @@ export class PlayerController {
81
114
  };
82
115
  }
83
116
  detectedBackends() {
84
- return [...(this.availableBackends ?? [])];
117
+ return [...(this.availableBackends ?? [])].filter(backend => backend !== 'airplay' || this.airPlayPolicy().enabled);
85
118
  }
86
119
  refreshDetectedBackends() {
87
- this.availableBackends = detectPlaybackBackends();
120
+ this.availableBackends = detectPlaybackBackends({ settings: this.getSettings(), platform: this.runtime.platform, env: this.runtime.env });
88
121
  return this.detectedBackends();
89
122
  }
90
- async play(station, url) {
123
+ async play(station, url, options = {}) {
124
+ const requestId = ++this.playRequestId;
125
+ const assertCurrent = () => {
126
+ options.signal?.throwIfAborted();
127
+ if (requestId !== this.playRequestId)
128
+ throw new DOMException('Playback request was superseded.', 'AbortError');
129
+ };
130
+ assertCurrent();
131
+ const abort = () => { if (requestId === this.playRequestId)
132
+ void this.stop().catch(() => undefined); };
133
+ options.signal?.addEventListener('abort', abort, { once: true });
134
+ try {
135
+ await this.playTarget(station, url, options, assertCurrent);
136
+ assertCurrent();
137
+ }
138
+ finally {
139
+ options.signal?.removeEventListener('abort', abort);
140
+ }
141
+ }
142
+ async playTarget(station, url, options, assertCurrent) {
91
143
  const target = safeMediaTarget(url);
92
144
  if (!target) {
93
145
  throw new Error(`Station ${station.name} returned an unsupported stream URL.`);
94
146
  }
95
147
  url = target;
148
+ if (this.stopPromise) {
149
+ await this.stopPromise;
150
+ assertCurrent();
151
+ }
96
152
  const backend = this.selectBackend();
153
+ if (options.startPaused && backend !== 'mpv')
154
+ throw new PlaybackOutputError('This output cannot start a station paused.');
97
155
  if (!backend) {
98
- await this.stop();
156
+ await this.stopPlayback();
99
157
  throw new PlaybackOutputError(this.playbackUnavailableMessage());
100
158
  }
101
159
  if (backend === 'airplay') {
102
160
  const activeDevice = this.activeAirPlayDeviceForRetune();
103
161
  if (activeDevice) {
104
162
  await this.retuneAirPlay(url, station.name, activeDevice);
163
+ assertCurrent();
105
164
  return;
106
165
  }
107
166
  let device;
108
167
  try {
109
168
  device = await this.resolveAirPlayDevice();
169
+ assertCurrent();
110
170
  }
111
171
  catch (error) {
172
+ assertCurrent();
112
173
  const message = error instanceof Error ? error.message : 'Could not resolve AirPlay receiver.';
113
174
  this.setState({ ...this.state, backend, state: 'error', message, ready: false });
114
175
  throw error;
115
176
  }
116
177
  if (this.canRetuneAirPlay(device)) {
117
178
  await this.retuneAirPlay(url, station.name, device);
179
+ assertCurrent();
118
180
  return;
119
181
  }
120
- await this.stop();
182
+ await this.stopPlayback();
183
+ assertCurrent();
121
184
  this.backend = backend;
122
185
  this.currentAirPlayDevice = device;
123
186
  this.currentAirPlayDeviceId = device.id;
@@ -133,6 +196,7 @@ export class PlayerController {
133
196
  });
134
197
  this.setState({ ...this.state, airPlayDeviceName: device.name });
135
198
  const result = await this.playWithAirPlay(url, station.name, device);
199
+ assertCurrent();
136
200
  if (result === 'password-required') {
137
201
  if (this.pendingAirPlayPasscode) {
138
202
  return;
@@ -161,8 +225,12 @@ export class PlayerController {
161
225
  });
162
226
  return;
163
227
  }
164
- await this.stop();
228
+ await this.stopPlayback();
229
+ assertCurrent();
165
230
  this.backend = backend;
231
+ this.stationIsRadio = station.provider !== 'playlist';
232
+ this.mpvMediaIsLive = false;
233
+ this.mpvProgress = null;
166
234
  this.setState({
167
235
  backend,
168
236
  state: 'loading',
@@ -174,20 +242,67 @@ export class PlayerController {
174
242
  ready: false
175
243
  });
176
244
  if (backend === 'mpv') {
177
- this.playWithMpv(url, station.name);
178
- await this.waitForReady(backend);
245
+ this.playWithMpv(url, station.name, undefined, options.startPaused);
246
+ try {
247
+ await this.waitForReady(backend);
248
+ assertCurrent();
249
+ }
250
+ catch (error) {
251
+ assertCurrent();
252
+ if (!this.shouldRetryMpvWithAlsa(error))
253
+ throw error;
254
+ await this.stopPlayback();
255
+ assertCurrent();
256
+ this.backend = backend;
257
+ this.setState({
258
+ backend,
259
+ state: 'loading',
260
+ message: `Opening ${station.name}`,
261
+ volume: this.getSettings().volume,
262
+ muted: false,
263
+ stationName: station.name,
264
+ streamUrl: url,
265
+ ready: false
266
+ });
267
+ this.playWithMpv(url, station.name, 'alsa,', options.startPaused);
268
+ await this.waitForReady(backend);
269
+ assertCurrent();
270
+ }
179
271
  }
180
272
  else if (backend === 'ffplay') {
181
- this.playWithFfplay(url);
182
- await this.waitForReady(backend);
273
+ const closed = this.playWithFfplay(url);
274
+ try {
275
+ await this.waitForReady(backend);
276
+ }
277
+ catch (error) {
278
+ assertCurrent();
279
+ if (this.process || this.lastPlayerExit?.spawnError || this.lastPlayerExit?.signal)
280
+ throw error;
281
+ // close follows stderr drain; exit can precede the diagnostic that
282
+ // distinguishes an unused TLS option from an actual network failure.
283
+ await Promise.race([closed, delay(250)]);
284
+ assertCurrent();
285
+ const inputFailure = ffmpegInputFailure(this.lastPlayerExit?.diagnostic ?? '');
286
+ if (inputFailure)
287
+ throw new PlaybackOutputError(inputFailure);
288
+ if (this.fallbackAudioStarted || !canRetryPlainHttp(url, this.lastPlayerExit?.diagnostic ?? ''))
289
+ throw error;
290
+ await this.stopPlayback();
291
+ assertCurrent();
292
+ this.setState({ ...this.state, state: 'loading', ready: false, message: `Opening ${station.name}` });
293
+ this.playWithFfplay(url, true);
294
+ await this.waitForReady(backend);
295
+ }
296
+ assertCurrent();
183
297
  }
184
298
  else if (backend === 'vlc') {
185
299
  this.playWithVlc(url);
186
300
  await this.waitForReady(backend);
301
+ assertCurrent();
187
302
  }
188
303
  this.setState({
189
304
  backend,
190
- state: 'playing',
305
+ state: options.startPaused ? 'paused' : 'playing',
191
306
  message: station.name,
192
307
  volume: this.getSettings().volume,
193
308
  muted: false,
@@ -209,6 +324,9 @@ export class PlayerController {
209
324
  return unsupported;
210
325
  }
211
326
  if (this.backend === 'mpv') {
327
+ if (this.state.state === 'paused') {
328
+ return this.resumeMpvAtLiveEdge();
329
+ }
212
330
  try {
213
331
  await this.sendMpv({ command: ['cycle', 'pause'] });
214
332
  }
@@ -244,8 +362,59 @@ export class PlayerController {
244
362
  return { ok: false, message: 'RadioCLI has no paused station to resume.' };
245
363
  return this.togglePause();
246
364
  }
247
- setVolume(volume) {
365
+ async resumeMpvAtLiveEdge() {
366
+ if (this.mpvLiveRetuneInFlight)
367
+ return { ok: true, message: 'Reconnecting at the live broadcast.' };
368
+ const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null;
369
+ if (!url)
370
+ return { ok: false, message: 'The paused station no longer has a usable stream URL.' };
371
+ const client = this.mpvIpcClient;
372
+ const session = this.mpvSessionId;
373
+ if (!client)
374
+ return { ok: false, message: 'mpv is no longer connected.' };
375
+ this.mpvLiveRetuneInFlight = true;
376
+ this.mpvRetuneFailure = undefined;
377
+ try {
378
+ // Reloading the same URL discards mpv's delayed live buffer and opens a
379
+ // fresh stream connection. This keeps radio pause/play semantics at the
380
+ // live edge instead of behaving like time-shifted on-demand audio.
381
+ await client.query({ command: ['loadfile', url, 'replace'] });
382
+ if (session !== this.mpvSessionId)
383
+ return { ok: false, message: 'Playback changed during resume.' };
384
+ await client.query({ command: ['set_property', 'pause', false] });
385
+ if (session !== this.mpvSessionId)
386
+ return { ok: false, message: 'Playback changed during resume.' };
387
+ if (this.mpvRetuneFailure)
388
+ throw new Error(this.mpvRetuneFailure);
389
+ await this.waitForReady('mpv');
390
+ const idle = await client.query({ command: ['get_property', 'idle-active'] }).catch(() => null);
391
+ if (this.mpvRetuneFailure || idle === true)
392
+ throw new Error(this.mpvRetuneFailure ?? 'The station ended while reconnecting.');
393
+ }
394
+ catch {
395
+ if (session === this.mpvSessionId) {
396
+ const terminal = { ...this.state, state: 'error', ready: false, buffering: false, message: this.mpvRetuneFailure ?? 'mpv could not reconnect the paused station at the live edge.' };
397
+ void this.retireMpvStream(terminal).catch(() => undefined);
398
+ }
399
+ return { ok: false, message: 'mpv could not reconnect the paused station at the live edge.' };
400
+ }
401
+ finally {
402
+ if (session === this.mpvSessionId)
403
+ this.mpvLiveRetuneInFlight = false;
404
+ }
405
+ if (session !== this.mpvSessionId)
406
+ return { ok: false, message: 'Playback changed during resume.' };
407
+ this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI';
408
+ this.emitMetadata({ updatedAt: new Date().toISOString() });
409
+ this.setState({ ...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString() });
410
+ return { ok: true, message: 'Reconnected at the live broadcast.' };
411
+ }
412
+ setVolume(volume, options = {}) {
248
413
  const clamped = clampVolume(volume);
414
+ if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) {
415
+ this.setState({ ...this.state, volume: clamped });
416
+ return Promise.resolve({ ok: true });
417
+ }
249
418
  const unsupported = this.unsupportedFfplayControl();
250
419
  if (unsupported) {
251
420
  return Promise.resolve(unsupported);
@@ -286,12 +455,41 @@ export class PlayerController {
286
455
  this.setState({ ...this.state, muted });
287
456
  return { ok: true };
288
457
  }
289
- async setMuted(muted) {
290
- if (this.state.muted === muted)
458
+ async setMuted(muted, options = {}) {
459
+ if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) {
460
+ this.setState({ ...this.state, muted });
291
461
  return { ok: true };
292
- return this.toggleMute();
462
+ }
463
+ const unsupported = this.unsupportedFfplayControl();
464
+ if (unsupported)
465
+ return this.state.muted === muted ? { ok: true } : unsupported;
466
+ // mpv can inherit mute=yes from its configuration, so the controller's
467
+ // startup default is not authoritative. Explicit setters must always reach
468
+ // the backend even when the requested value matches our local state.
469
+ if (this.backend === 'mpv') {
470
+ try {
471
+ await this.sendMpv({ command: ['set_property', 'mute', muted] });
472
+ }
473
+ catch {
474
+ return { ok: false, message: 'mpv did not acknowledge the mute change.' };
475
+ }
476
+ }
477
+ else if (this.backend === 'airplay') {
478
+ if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
479
+ return { ok: false, message: 'The AirPlay worker is not available.' };
480
+ }
481
+ }
482
+ else if (this.state.muted === muted) {
483
+ return { ok: true };
484
+ }
485
+ this.setState({ ...this.state, muted });
486
+ return { ok: true };
293
487
  }
294
488
  stop() {
489
+ this.playRequestId++;
490
+ return this.stopPlayback();
491
+ }
492
+ stopPlayback() {
295
493
  if (this.stopPromise)
296
494
  return this.stopPromise;
297
495
  const operation = this.performStop();
@@ -302,6 +500,7 @@ export class PlayerController {
302
500
  }
303
501
  async performStop() {
304
502
  const child = this.process;
503
+ this.mpvMediaIsLive = false;
305
504
  const startupInProgress = this.state.state === 'loading' && !this.state.ready;
306
505
  this.stopMpvPolling();
307
506
  this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
@@ -334,7 +533,11 @@ export class PlayerController {
334
533
  ready: false
335
534
  });
336
535
  }
536
+ airPlayPolicy() { return airPlayPolicy(this.getSettings(), this.runtime); }
337
537
  submitAirPlayPasscode(code) {
538
+ const policy = this.airPlayPolicy();
539
+ if (!policy.enabled)
540
+ return { ok: false, message: policy.message };
338
541
  if (this.backend !== 'airplay') {
339
542
  return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
340
543
  }
@@ -353,15 +556,34 @@ export class PlayerController {
353
556
  return { ok: true, message };
354
557
  }
355
558
  async refreshAirPlayDevices() {
356
- this.availableAirPlayDevices = await discoverAirPlayDevices();
559
+ const policy = this.airPlayPolicy();
560
+ if (!policy.enabled)
561
+ throw new PlaybackOutputError(policy.message);
562
+ this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform, env: this.runtime.env });
357
563
  return [...this.availableAirPlayDevices];
358
564
  }
359
565
  detectedAirPlayDevices() {
360
566
  return [...this.availableAirPlayDevices];
361
567
  }
568
+ selectAirPlayDevice(deviceId) {
569
+ if (deviceId !== null && !this.airPlayPolicy().enabled)
570
+ return false;
571
+ if (deviceId !== null && !this.availableAirPlayDevices.some(device => device.id === deviceId && !device.local))
572
+ return false;
573
+ this.selectedAirPlayDeviceId = deviceId;
574
+ return true;
575
+ }
576
+ airPlaySelectionRequired() {
577
+ return this.runtime.platform === 'linux' && (!this.selectedAirPlayDeviceId || this.selectedAirPlayDeviceId !== this.getSettings().preferredAirPlayDevice);
578
+ }
579
+ effectivePreferredBackend() {
580
+ // A persisted receiver is a convenience for the picker, never consent to
581
+ // open a Linux network output again in a new process.
582
+ return this.getSettings().preferredBackend === 'airplay' && (this.runtime.platform === 'linux' && !this.airPlayPolicy().enabled || this.airPlaySelectionRequired()) ? 'auto' : this.getSettings().preferredBackend;
583
+ }
362
584
  selectBackend() {
363
- const preferred = this.getSettings().preferredBackend;
364
- const backends = this.availableBackends ?? this.refreshDetectedBackends();
585
+ const preferred = this.effectivePreferredBackend();
586
+ const backends = this.availableBackends ? this.detectedBackends() : this.refreshDetectedBackends();
365
587
  if (preferred === 'mpv') {
366
588
  return backends.includes('mpv') ? 'mpv' : null;
367
589
  }
@@ -388,25 +610,41 @@ export class PlayerController {
388
610
  playbackUnavailableMessage() {
389
611
  const preferred = this.getSettings().preferredBackend;
390
612
  if (preferred === 'airplay') {
391
- return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
613
+ const policy = this.airPlayPolicy();
614
+ if (!policy.enabled)
615
+ return policy.message;
616
+ return `${this.runtime.platform === 'linux' ? airPlaySetupRequiredMessage : 'AirPlay is not ready on this install. Run radiocli doctor.'} ${airPlaySenderHealth().message}`;
392
617
  }
393
618
  if (preferred === 'mpv' || preferred === 'ffplay') {
394
619
  return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
395
620
  }
396
621
  return `No playback backend found. ${playbackBackendInstallHint()}`;
397
622
  }
398
- playWithMpv(url, initialTitle) {
399
- this.ipcPath = createMpvIpcPath();
623
+ playWithMpv(url, initialTitle, recoveredAudioOutput, startPaused = false) {
624
+ this.playbackSessionId += 1;
625
+ this.ipcPath = mpvIpcPath();
400
626
  this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
401
627
  this.mpvSessionId += 1;
402
628
  this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
403
629
  this.pendingMpvVolume = null;
404
630
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
631
+ const desktopMedia = desktopMediaPolicy(this.getSettings(), this.runtime);
632
+ this.compatibleDesktopMedia = desktopMedia.enabled;
633
+ // Release the application service before an incompatible mpv process can
634
+ // autoload its own service, including config changes made between tunes.
635
+ this.setState({ ...this.state });
405
636
  this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
406
637
  '--no-video',
407
- '--really-quiet',
638
+ '--msg-level=all=warn',
408
639
  '--force-window=no',
409
- ...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []),
640
+ // Several supported mpv/FFmpeg builds default to accepting any certificate.
641
+ // Apply to every input, including HTTP redirects and nested playlists.
642
+ '--tls-verify=yes',
643
+ ...(startPaused ? ['--pause=yes'] : []),
644
+ ...desktopMedia.mpvArgs,
645
+ ...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
646
+ ? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
647
+ : []),
410
648
  `--force-media-title=${this.currentMpvMediaTitle}`,
411
649
  `--volume=${this.getSettings().volume}`,
412
650
  `--input-ipc-server=${this.ipcPath}`,
@@ -416,18 +654,22 @@ export class PlayerController {
416
654
  });
417
655
  this.wireProcess();
418
656
  }
419
- playWithFfplay(url) {
420
- this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
657
+ playWithFfplay(url, plainHttp = false) {
658
+ this.playbackSessionId += 1;
659
+ this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-stats', ...ffmpegInputArguments(url, { plainHttp }), '-volume', String(this.getSettings().volume), '-autoexit', url], {
421
660
  stdio: ['pipe', 'pipe', 'pipe']
422
661
  });
423
662
  this.wireProcess();
663
+ return new Promise(resolve => this.process.once('close', resolve));
424
664
  }
425
665
  playWithVlc(url) {
666
+ this.playbackSessionId += 1;
426
667
  // VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
427
668
  // binary (resolved from app bundles when off PATH) with a dummy interface.
428
669
  const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
429
670
  const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2);
430
- this.process = spawn(binary, ['--intf', 'dummy', '--no-video', '--quiet', '--play-and-exit', `--gain=${gain}`, url], { stdio: ['pipe', 'pipe', 'pipe'] });
671
+ this.process = spawn(binary, ['--intf', 'dummy', '--no-video', '--no-quiet', '--verbose=2', '--no-color', '--play-and-exit',
672
+ ...(this.runtime.env.RADIOCLI_VLC_AUDIO_OUTPUT ? [`--aout=${this.runtime.env.RADIOCLI_VLC_AUDIO_OUTPUT}`] : []), `--gain=${gain}`, url], { stdio: ['pipe', 'pipe', 'pipe'] });
431
673
  this.wireProcess();
432
674
  }
433
675
  canRetuneAirPlay(device) {
@@ -510,7 +752,7 @@ export class PlayerController {
510
752
  throw new PlaybackOutputError('Selected AirPlay receiver was not found. Refresh AirPlay receivers in Settings.');
511
753
  }
512
754
  if (device.local) {
513
- throw new PlaybackOutputError(`${device.name} is this Mac. Use Audio output: This device instead of AirPlay.`);
755
+ throw new PlaybackOutputError(`${device.name} is ${airPlayLocalDeviceLabel(this.runtime.platform)}. Use Audio output: This device instead of AirPlay.`);
514
756
  }
515
757
  return device;
516
758
  }
@@ -650,24 +892,84 @@ export class PlayerController {
650
892
  }
651
893
  wireProcess() {
652
894
  const child = this.process;
653
- if (!child) {
895
+ const backend = this.backend;
896
+ if (!child || !backend) {
654
897
  return;
655
898
  }
899
+ this.lastPlayerExit = null;
900
+ this.activePlayerDiagnostic = '';
901
+ this.fallbackAudioStarted = false;
902
+ let progressBuffer = '';
903
+ let vlcAudioOutput = false;
904
+ let vlcDecoderStarted = false;
905
+ let diagnostic = '';
906
+ let exitRecord;
907
+ child.stdout.on('data', chunk => {
908
+ diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
909
+ if (this.process === child)
910
+ this.activePlayerDiagnostic = diagnostic;
911
+ });
912
+ child.stderr.on('data', chunk => {
913
+ diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
914
+ if (this.process === child)
915
+ this.activePlayerDiagnostic = diagnostic;
916
+ if (backend === 'ffplay' && this.process === child && !this.fallbackAudioStarted) {
917
+ progressBuffer = appendPlayerDiagnostic(progressBuffer, String(chunk));
918
+ const lines = progressBuffer.split(/[\r\n]/);
919
+ progressBuffer = lines.pop() ?? '';
920
+ // ffplay's audio-master clock is nan until audio reaches its output.
921
+ // Explicit -stats keeps this signal even with error-only diagnostics.
922
+ this.fallbackAudioStarted = lines.some(line => /^\s*-?\d+(?:\.\d+)?\s+M-A:\s*-?\d+(?:\.\d+)?\s+/.test(line));
923
+ }
924
+ else if (backend === 'vlc' && this.process === child && !this.fallbackAudioStarted) {
925
+ // Keep the portable dummy interface. RC stdin opens a separate console
926
+ // on Windows, and a loopback RC port would expose unauthenticated control.
927
+ // Both output initialization and released decoder buffering are needed;
928
+ // input headers or a surviving process alone never establish readiness.
929
+ vlcAudioOutput ||= /main audio output debug: output '[^'\r\n]+' \d+ Hz/.test(diagnostic);
930
+ vlcDecoderStarted ||= /main input debug: Decoder wait done in \d+ ms/.test(diagnostic);
931
+ this.fallbackAudioStarted = vlcAudioOutput && vlcDecoderStarted;
932
+ }
933
+ });
656
934
  // Local players can write diagnostics indefinitely. Drain both pipes so a
657
935
  // full OS pipe buffer can never stall playback.
658
936
  child.stdout.resume?.();
659
937
  child.stderr.resume?.();
938
+ child.once('close', () => {
939
+ if (exitRecord && this.lastPlayerExit === exitRecord)
940
+ exitRecord.diagnostic = diagnostic;
941
+ });
660
942
  child.on('error', error => {
943
+ diagnostic = appendPlayerDiagnostic(diagnostic, error.message);
944
+ if (this.process !== child)
945
+ return;
946
+ this.lastPlayerExit = { backend, code: null, signal: null, diagnostic, spawnError: true };
947
+ this.rejectPendingAirPlayReady(error);
948
+ this.rejectPendingAirPlayRetune(error);
949
+ if (backend === 'airplay') {
950
+ this.currentAirPlayDevice = null;
951
+ this.currentAirPlayDeviceId = null;
952
+ this.pendingAirPlayPasscode = null;
953
+ this.airPlaySessionEstablished = false;
954
+ }
955
+ this.process = null;
956
+ this.stopMpvPolling();
957
+ this.cleanupIpc();
661
958
  this.setState({
662
959
  ...this.state,
663
- backend: this.backend ?? 'none',
960
+ backend,
664
961
  state: 'error',
665
962
  message: error.message,
666
963
  ready: false
667
964
  });
668
965
  });
669
- child.on('exit', code => {
966
+ child.on('exit', (code, signal) => {
670
967
  if (this.process === child) {
968
+ // Worker failures carry receiver/pairing guidance before cleanup exits.
969
+ // Keep that reason while still clearing the exited process and session.
970
+ const workerError = backend === 'airplay' && this.state.state === 'error' ? this.state.message || undefined : undefined;
971
+ const liveEof = backend === 'mpv' && this.mpvMediaIsLive && this.state.ready && code === 0 && !signal;
972
+ this.lastPlayerExit = exitRecord = { backend, code: liveEof ? 1 : code, signal, diagnostic, transportFailure: liveEof };
671
973
  this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
672
974
  if (this.backend === 'airplay') {
673
975
  this.currentAirPlayDevice = null;
@@ -681,8 +983,9 @@ export class PlayerController {
681
983
  this.setState({
682
984
  ...this.state,
683
985
  backend: this.backend ?? 'none',
684
- state: code === 0 || code === null ? 'stopped' : 'error',
685
- message: code === 0 || code === null ? undefined : `player exited with code ${code}`,
986
+ state: workerError || liveEof || (code !== 0 && code !== null) ? 'error' : 'stopped',
987
+ message: workerError ?? (liveEof ? 'Live stream connection ended unexpectedly.' : code === 0 || code === null ? undefined : `player exited with code ${code}`),
988
+ buffering: false,
686
989
  ready: false
687
990
  });
688
991
  }
@@ -789,18 +1092,26 @@ export class PlayerController {
789
1092
  async waitForReady(backend) {
790
1093
  const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
791
1094
  const started = Date.now();
1095
+ const child = this.process;
1096
+ const playbackSessionId = this.playbackSessionId;
1097
+ const mpvClient = this.mpvIpcClient;
1098
+ const wasSuperseded = () => this.playbackSessionId !== playbackSessionId;
1099
+ const isCurrent = () => !wasSuperseded() && this.process === child;
792
1100
  while (Date.now() - started < timeoutMs) {
793
- if (!this.process) {
794
- throw new Error('Player exited before the stream became ready.');
1101
+ if (backend === 'mpv' && this.mpvLiveRetuneInFlight && this.mpvRetuneFailure)
1102
+ throw new Error(this.mpvRetuneFailure);
1103
+ if (wasSuperseded()) {
1104
+ throw new Error(`${backend} playback request was superseded.`);
795
1105
  }
796
- if (backend === 'ffplay' || backend === 'vlc') {
797
- await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
798
- return;
1106
+ if (!child || this.process !== child) {
1107
+ throw this.playerExitedBeforeReady(backend);
799
1108
  }
800
- if (this.ipcPath) {
1109
+ if ((backend === 'ffplay' || backend === 'vlc') && this.fallbackAudioStarted)
1110
+ return;
1111
+ if (mpvClient) {
801
1112
  try {
802
- await this.queryMpv({ command: ['get_property', 'path'] });
803
- if (await this.hasMpvAudioStarted()) {
1113
+ await mpvClient.query({ command: ['get_property', 'path'] });
1114
+ if (await this.hasMpvAudioStarted(mpvClient) && isCurrent()) {
804
1115
  return;
805
1116
  }
806
1117
  }
@@ -810,18 +1121,107 @@ export class PlayerController {
810
1121
  }
811
1122
  await delay(150);
812
1123
  }
813
- await this.stop();
814
- throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
815
- }
816
- async hasMpvAudioStarted() {
1124
+ if (wasSuperseded()) {
1125
+ throw new Error(`${backend} playback request was superseded.`);
1126
+ }
1127
+ if (this.process !== child)
1128
+ throw this.playerExitedBeforeReady(backend);
1129
+ const requestId = this.playRequestId;
1130
+ const diagnostic = this.activePlayerDiagnostic;
1131
+ await this.stopPlayback();
1132
+ if (requestId !== this.playRequestId || wasSuperseded())
1133
+ throw new DOMException('Playback request was superseded.', 'AbortError');
1134
+ const detail = concisePlayerDiagnostic(diagnostic);
1135
+ const message = `Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.${detail ? ` ${detail}` : ''}`;
1136
+ this.lastPlayerExit = { backend, code: 1, signal: null, diagnostic };
1137
+ this.setState({ ...this.state, state: 'error', ready: false, buffering: false, message });
1138
+ throw new Error(message);
1139
+ }
1140
+ shouldRetryMpvWithAlsa(error) {
1141
+ return this.runtime.platform === 'linux' &&
1142
+ (this.runtime.arch === 'arm' || this.runtime.arch === 'arm64') &&
1143
+ !this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT &&
1144
+ isAudioOutputInitializationFailure(this.lastPlayerExit?.diagnostic ?? '') &&
1145
+ isPlaybackOutputError(error);
1146
+ }
1147
+ playerExitedBeforeReady(backend) {
1148
+ const exit = this.lastPlayerExit?.backend === backend ? this.lastPlayerExit : null;
1149
+ const reason = exit
1150
+ ? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}`
1151
+ : 'before its exit status was available';
1152
+ const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? '');
1153
+ const inputFailure = backend === 'ffplay' ? ffmpegInputFailure(exit?.diagnostic ?? '') : undefined;
1154
+ if (inputFailure)
1155
+ return new PlaybackOutputError(inputFailure);
1156
+ const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`;
1157
+ return isBackendInitializationFailure(backend, exit, diagnostic)
1158
+ ? new PlaybackOutputError(message)
1159
+ : new Error(message);
1160
+ }
1161
+ async hasMpvAudioStarted(client) {
817
1162
  const [timePos, audioPts] = await Promise.all([
818
- this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null),
819
- this.queryMpv({ command: ['get_property', 'audio-pts'] }).catch(() => null)
1163
+ client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
1164
+ client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
820
1165
  ]);
821
- return typeof timePos === 'number' || typeof audioPts === 'number';
1166
+ if (!Number.isFinite(timePos) && !Number.isFinite(audioPts))
1167
+ return false;
1168
+ const [duration, seekable] = await Promise.all([
1169
+ client.query({ command: ['get_property', 'duration'] }).catch(() => null),
1170
+ client.query({ command: ['get_property', 'seekable'] }).catch(() => null)
1171
+ ]);
1172
+ if (client === this.mpvIpcClient) {
1173
+ // Directory stations are live even when HLS exposes a finite window.
1174
+ // For imported URLs, a known duration denotes a recording; nonseekable
1175
+ // inputs without a duration can still be live radio.
1176
+ this.mpvMediaIsLive = /^https?:\/\//i.test(this.state.streamUrl ?? '') &&
1177
+ (this.stationIsRadio || seekable === false && !(typeof duration === 'number' && Number.isFinite(duration) && duration > 0));
1178
+ }
1179
+ return true;
822
1180
  }
823
1181
  startMpvMetadataPolling() {
824
1182
  this.stopMpvPolling();
1183
+ const client = this.mpvIpcClient;
1184
+ const session = this.mpvSessionId;
1185
+ this.mpvObserved = false;
1186
+ client?.onEvent(event => {
1187
+ if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
1188
+ return;
1189
+ if (event.event === 'end-file' && this.state.ready && ['eof', 'error'].includes(event.reason ?? '')) {
1190
+ if (this.mpvLiveRetuneInFlight) {
1191
+ this.mpvRetuneFailure = event.error ?? 'The station ended while reconnecting.';
1192
+ return;
1193
+ }
1194
+ const liveEof = event.reason === 'eof' && this.mpvMediaIsLive;
1195
+ const terminal = { ...this.state, state: event.reason === 'error' || liveEof ? 'error' : 'stopped', ready: false, buffering: false,
1196
+ message: event.reason === 'error' ? event.error ?? 'mpv could not continue the stream.' : liveEof ? 'Live stream connection ended unexpectedly.' : 'Stream ended.' };
1197
+ // User mpv configuration may keep an idle process alive after EOF.
1198
+ // Retire it through the same bounded lifecycle as an explicit Stop.
1199
+ void this.retireMpvStream(terminal, liveEof).catch(() => undefined);
1200
+ return;
1201
+ }
1202
+ if (event.event !== 'property-change')
1203
+ return;
1204
+ if (event.name === 'pause' && typeof event.data === 'boolean' && this.state.ready) {
1205
+ void this.applyMpvPause(event.data).catch(() => undefined);
1206
+ }
1207
+ else if (event.name === 'metadata' && event.data && typeof event.data === 'object') {
1208
+ void this.applyMpvMetadata(event.data, client, session);
1209
+ }
1210
+ else if (event.name === 'paused-for-cache' && typeof event.data === 'boolean' && this.state.buffering !== event.data) {
1211
+ this.setState({ ...this.state, buffering: event.data });
1212
+ }
1213
+ });
1214
+ if (client)
1215
+ void Promise.all(['pause', 'metadata', 'paused-for-cache'].map((property, id) => client.observe(id + 1, property)))
1216
+ .then(() => {
1217
+ if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
1218
+ return;
1219
+ this.mpvObserved = true;
1220
+ if (this.playbackStateTimer)
1221
+ clearInterval(this.playbackStateTimer);
1222
+ this.playbackStateTimer = setInterval(() => { if (!this.playbackStatePollInFlight)
1223
+ void this.syncMpvPlaybackState(); }, 5000);
1224
+ }).catch(() => undefined);
825
1225
  this.metadataTimer = setInterval(() => {
826
1226
  if (!this.metadataPollInFlight)
827
1227
  void this.pollMpvMetadata();
@@ -832,8 +1232,11 @@ export class PlayerController {
832
1232
  }, 500);
833
1233
  void this.pollMpvMetadata();
834
1234
  void this.syncMpvPlaybackState();
1235
+ this.updateMpvStallTimer();
835
1236
  }
836
1237
  stopMpvPolling() {
1238
+ clearTimeout(this.mpvStallTimer);
1239
+ this.mpvStallTimer = undefined;
837
1240
  if (this.metadataTimer) {
838
1241
  clearInterval(this.metadataTimer);
839
1242
  this.metadataTimer = null;
@@ -843,27 +1246,59 @@ export class PlayerController {
843
1246
  this.playbackStateTimer = null;
844
1247
  }
845
1248
  }
1249
+ async retireMpvStream(terminal, transportFailure = false) {
1250
+ const request = this.playRequestId;
1251
+ const failure = terminal.state === 'error' ? { backend: 'mpv', code: 1, signal: null, diagnostic: terminal.message ?? '', transportFailure } : null;
1252
+ this.lastPlayerExit = failure;
1253
+ this.stopMpvPolling();
1254
+ this.setState(terminal);
1255
+ await this.stopPlayback();
1256
+ if (request === this.playRequestId) {
1257
+ this.lastPlayerExit = failure;
1258
+ this.setState(terminal);
1259
+ }
1260
+ }
846
1261
  async pollMpvMetadata() {
847
1262
  if (this.backend !== 'mpv' || !this.process) {
848
1263
  return;
849
1264
  }
1265
+ const client = this.mpvIpcClient;
1266
+ const session = this.mpvSessionId;
1267
+ if (!client)
1268
+ return;
850
1269
  this.metadataPollInFlight = true;
851
1270
  try {
852
- const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
853
- const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
854
- if (typeof elapsed === 'number') {
1271
+ const metadata = this.mpvObserved ? null : await client.query({ command: ['get_property', 'metadata'] }).catch(() => null);
1272
+ let elapsed = await client.query({ command: ['get_property', 'time-pos'] }).catch(() => null);
1273
+ if (!Number.isFinite(elapsed))
1274
+ elapsed = await client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null);
1275
+ if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
1276
+ return;
1277
+ if (typeof elapsed === 'number' && Number.isFinite(elapsed)) {
1278
+ if (elapsed !== this.mpvProgress) {
1279
+ this.mpvProgress = elapsed;
1280
+ clearTimeout(this.mpvStallTimer);
1281
+ this.mpvStallTimer = undefined;
1282
+ }
855
1283
  this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
856
1284
  }
857
- const title = extractMpvTitle(metadata);
858
- if (title) {
859
- await this.setMpvMediaTitle(title);
860
- this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
861
- }
1285
+ if (metadata)
1286
+ await this.applyMpvMetadata(metadata, client, session);
862
1287
  }
863
1288
  finally {
864
- this.metadataPollInFlight = false;
1289
+ if (session === this.mpvSessionId)
1290
+ this.metadataPollInFlight = false;
865
1291
  }
866
1292
  }
1293
+ async applyMpvMetadata(metadata, client, session) {
1294
+ const title = extractMpvTitle(metadata);
1295
+ if (!title)
1296
+ return;
1297
+ await this.setMpvMediaTitle(title);
1298
+ if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
1299
+ return;
1300
+ this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
1301
+ }
867
1302
  async setMpvMediaTitle(title) {
868
1303
  const cleaned = cleanMediaTitle(title);
869
1304
  if (!cleaned || cleaned === this.currentMpvMediaTitle) {
@@ -878,22 +1313,35 @@ export class PlayerController {
878
1313
  }
879
1314
  if (this.playbackStatePollInFlight)
880
1315
  return false;
1316
+ const client = this.mpvIpcClient;
1317
+ const session = this.mpvSessionId;
1318
+ if (!client)
1319
+ return false;
881
1320
  this.playbackStatePollInFlight = true;
882
1321
  try {
883
- const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
884
- if (typeof paused !== 'boolean') {
1322
+ const paused = await client.query({ command: ['get_property', 'pause'] }).catch(() => null);
1323
+ if (typeof paused !== 'boolean' || session !== this.mpvSessionId || !this.state.ready) {
885
1324
  return false;
886
1325
  }
887
- const state = paused ? 'paused' : 'playing';
888
- if (this.state.state !== state) {
889
- this.setState({ ...this.state, state });
890
- }
891
- return true;
1326
+ return this.applyMpvPause(paused);
892
1327
  }
893
1328
  finally {
894
- this.playbackStatePollInFlight = false;
1329
+ if (session === this.mpvSessionId)
1330
+ this.playbackStatePollInFlight = false;
895
1331
  }
896
1332
  }
1333
+ async applyMpvPause(paused) {
1334
+ // Unpausing the decoder is only the first step of a live-edge reload.
1335
+ // Publish audible playback after the new stream passes readiness checks.
1336
+ if (this.mpvLiveRetuneInFlight)
1337
+ return true;
1338
+ if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight)
1339
+ return (await this.resumeMpvAtLiveEdge()).ok;
1340
+ const state = paused ? 'paused' : 'playing';
1341
+ if (this.state.state !== state)
1342
+ this.setState({ ...this.state, state });
1343
+ return true;
1344
+ }
897
1345
  emitMetadata(metadata) {
898
1346
  for (const listener of this.metadataListeners) {
899
1347
  try {
@@ -910,6 +1358,11 @@ export class PlayerController {
910
1358
  this.mpvIpcClient = null;
911
1359
  this.pendingMpvVolume = null;
912
1360
  this.mpvVolumeFlush = null;
1361
+ this.metadataPollInFlight = false;
1362
+ this.playbackStatePollInFlight = false;
1363
+ this.mpvLiveRetuneInFlight = false;
1364
+ this.mpvRetuneFailure = undefined;
1365
+ this.mpvObserved = false;
913
1366
  if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
914
1367
  try {
915
1368
  unlinkSync(this.ipcPath);
@@ -923,16 +1376,29 @@ export class PlayerController {
923
1376
  }
924
1377
  setState(state) {
925
1378
  this.state = state;
1379
+ this.updateMpvStallTimer();
926
1380
  for (const listener of this.listeners) {
927
1381
  listener(state);
928
1382
  }
929
1383
  }
930
- }
931
- export function createMpvIpcPath(platform = process.platform, pid = process.pid, timestamp = Date.now()) {
932
- if (platform === 'win32') {
933
- return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
1384
+ updateMpvStallTimer() {
1385
+ if (!this.mpvMediaIsLive || this.backend !== 'mpv' || !this.state.ready || this.state.state !== 'playing' || this.mpvLiveRetuneInFlight) {
1386
+ clearTimeout(this.mpvStallTimer);
1387
+ this.mpvStallTimer = undefined;
1388
+ return;
1389
+ }
1390
+ if (this.mpvStallTimer)
1391
+ return;
1392
+ const session = this.mpvSessionId;
1393
+ this.mpvStallTimer = setTimeout(() => {
1394
+ this.mpvStallTimer = undefined;
1395
+ if (session !== this.mpvSessionId || !this.mpvMediaIsLive || !this.state.ready || this.state.state !== 'playing')
1396
+ return;
1397
+ void this.retireMpvStream({ ...this.state, state: 'error', ready: false, buffering: false,
1398
+ message: 'Live stream timed out without audio progress.' }, true).catch(() => undefined);
1399
+ }, Math.max(15_000, this.getSettings().tuneTimeoutSeconds * 1000));
1400
+ this.mpvStallTimer.unref();
934
1401
  }
935
- return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
936
1402
  }
937
1403
  function airPlayWorkerPath() {
938
1404
  const currentPath = fileURLToPath(import.meta.url);
@@ -993,14 +1459,35 @@ export function extractMpvTitle(metadata) {
993
1459
  }
994
1460
  return undefined;
995
1461
  }
996
- async function waitForStartupWindow(getProcess, ms) {
997
- const started = Date.now();
998
- while (Date.now() - started < ms) {
999
- if (!getProcess()) {
1000
- throw new Error('Player exited before the stream became ready.');
1001
- }
1002
- await delay(Math.min(100, ms - (Date.now() - started)));
1003
- }
1462
+ function appendPlayerDiagnostic(current, chunk) {
1463
+ const combined = current + chunk;
1464
+ return combined.length <= maxPlayerDiagnosticCharacters
1465
+ ? combined
1466
+ : combined.slice(-maxPlayerDiagnosticCharacters);
1467
+ }
1468
+ function concisePlayerDiagnostic(value) {
1469
+ const lines = value
1470
+ .split(/\r?\n/)
1471
+ .map(line => sanitizeTerminalText(line.replace(/https?:\/\/\S+/gi, '[stream URL]')))
1472
+ .filter((line) => Boolean(line))
1473
+ .slice(-3)
1474
+ .join(' · ')
1475
+ .slice(0, 700);
1476
+ return lines;
1477
+ }
1478
+ function isAudioOutputInitializationFailure(value) {
1479
+ return /audio output initialization failed|could not open\/initialize audio device|failed to initialize audio driver/i.test(value);
1480
+ }
1481
+ function isBackendInitializationFailure(backend, exit, diagnostic) {
1482
+ if (isAudioOutputInitializationFailure(diagnostic))
1483
+ return true;
1484
+ if (exit?.spawnError)
1485
+ return true;
1486
+ if (/unknown (?:command line )?option|error initializing|failed to (?:create|connect to) .*(?:socket|pipe)/i.test(diagnostic))
1487
+ return true;
1488
+ // mpv reserves status 1 for player initialization and invalid options. A
1489
+ // stream that could not be played uses status 2 and remains skippable.
1490
+ return backend === 'mpv' && exit?.code === 1;
1004
1491
  }
1005
1492
  function cleanMetadataTitle(value) {
1006
1493
  const normalized = cleanMediaTitle(value);