@ciphore/radiocli 0.2.4 → 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 (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -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 +178 -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 +113 -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 +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -2,14 +2,17 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, unlinkSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { airPlayMacOSOnlyMessage, detectPlaybackBackends, ffplayLimitedControlsMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
5
+ import { airPlayLocalDeviceLabel, detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
6
6
  import { resolveCommand } from '../platform/executables.js';
7
+ import { airPlayPolicy, airPlaySetupRequiredMessage } from './airplay-policy.js';
7
8
  import { discoverAirPlayDevices } from './airplay-discovery.js';
8
9
  import { airPlaySenderHealth } from './airplay-sender-health.js';
9
10
  import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
10
11
  import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
11
12
  import { MpvIpcClient } from './mpv-ipc-client.js';
12
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;
14
17
  const maxPlayerDiagnosticCharacters = 4096;
15
18
  export class PlaybackOutputError extends Error {
@@ -44,11 +47,13 @@ export class PlayerController {
44
47
  airPlayPasscodes = new Map();
45
48
  currentAirPlayDevice = null;
46
49
  currentAirPlayDeviceId = null;
50
+ selectedAirPlayDeviceId = null;
47
51
  pendingAirPlayPasscode = null;
48
52
  currentMpvMediaTitle = null;
49
53
  metadataPollInFlight = false;
50
54
  playbackStatePollInFlight = false;
51
55
  mpvLiveRetuneInFlight = false;
56
+ mpvRetuneFailure;
52
57
  pendingMpvVolume = null;
53
58
  mpvVolumeFlush = null;
54
59
  confirmedMpvVolume = 70;
@@ -56,6 +61,15 @@ export class PlayerController {
56
61
  playbackSessionId = 0;
57
62
  stopPromise = null;
58
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;
59
73
  constructor(getSettings, runtime = { platform: process.platform, arch: process.arch, env: process.env }) {
60
74
  this.getSettings = getSettings;
61
75
  this.runtime = runtime;
@@ -72,11 +86,24 @@ export class PlayerController {
72
86
  getState() {
73
87
  return { ...this.state };
74
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
+ }
75
102
  diagnostics() {
76
103
  return {
77
104
  backend: this.state.backend,
78
105
  availableBackends: this.detectedBackends(),
79
- preferredBackend: this.getSettings().preferredBackend,
106
+ preferredBackend: this.effectivePreferredBackend(),
80
107
  active: Boolean(this.process),
81
108
  streamUrl: this.state.streamUrl,
82
109
  stationName: this.state.stationName,
@@ -87,43 +114,73 @@ export class PlayerController {
87
114
  };
88
115
  }
89
116
  detectedBackends() {
90
- return [...(this.availableBackends ?? [])];
117
+ return [...(this.availableBackends ?? [])].filter(backend => backend !== 'airplay' || this.airPlayPolicy().enabled);
91
118
  }
92
119
  refreshDetectedBackends() {
93
- this.availableBackends = detectPlaybackBackends({ platform: this.runtime.platform });
120
+ this.availableBackends = detectPlaybackBackends({ settings: this.getSettings(), platform: this.runtime.platform, env: this.runtime.env });
94
121
  return this.detectedBackends();
95
122
  }
96
- 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) {
97
143
  const target = safeMediaTarget(url);
98
144
  if (!target) {
99
145
  throw new Error(`Station ${station.name} returned an unsupported stream URL.`);
100
146
  }
101
147
  url = target;
148
+ if (this.stopPromise) {
149
+ await this.stopPromise;
150
+ assertCurrent();
151
+ }
102
152
  const backend = this.selectBackend();
153
+ if (options.startPaused && backend !== 'mpv')
154
+ throw new PlaybackOutputError('This output cannot start a station paused.');
103
155
  if (!backend) {
104
- await this.stop();
156
+ await this.stopPlayback();
105
157
  throw new PlaybackOutputError(this.playbackUnavailableMessage());
106
158
  }
107
159
  if (backend === 'airplay') {
108
160
  const activeDevice = this.activeAirPlayDeviceForRetune();
109
161
  if (activeDevice) {
110
162
  await this.retuneAirPlay(url, station.name, activeDevice);
163
+ assertCurrent();
111
164
  return;
112
165
  }
113
166
  let device;
114
167
  try {
115
168
  device = await this.resolveAirPlayDevice();
169
+ assertCurrent();
116
170
  }
117
171
  catch (error) {
172
+ assertCurrent();
118
173
  const message = error instanceof Error ? error.message : 'Could not resolve AirPlay receiver.';
119
174
  this.setState({ ...this.state, backend, state: 'error', message, ready: false });
120
175
  throw error;
121
176
  }
122
177
  if (this.canRetuneAirPlay(device)) {
123
178
  await this.retuneAirPlay(url, station.name, device);
179
+ assertCurrent();
124
180
  return;
125
181
  }
126
- await this.stop();
182
+ await this.stopPlayback();
183
+ assertCurrent();
127
184
  this.backend = backend;
128
185
  this.currentAirPlayDevice = device;
129
186
  this.currentAirPlayDeviceId = device.id;
@@ -139,6 +196,7 @@ export class PlayerController {
139
196
  });
140
197
  this.setState({ ...this.state, airPlayDeviceName: device.name });
141
198
  const result = await this.playWithAirPlay(url, station.name, device);
199
+ assertCurrent();
142
200
  if (result === 'password-required') {
143
201
  if (this.pendingAirPlayPasscode) {
144
202
  return;
@@ -167,8 +225,12 @@ export class PlayerController {
167
225
  });
168
226
  return;
169
227
  }
170
- await this.stop();
228
+ await this.stopPlayback();
229
+ assertCurrent();
171
230
  this.backend = backend;
231
+ this.stationIsRadio = station.provider !== 'playlist';
232
+ this.mpvMediaIsLive = false;
233
+ this.mpvProgress = null;
172
234
  this.setState({
173
235
  backend,
174
236
  state: 'loading',
@@ -180,14 +242,17 @@ export class PlayerController {
180
242
  ready: false
181
243
  });
182
244
  if (backend === 'mpv') {
183
- this.playWithMpv(url, station.name);
245
+ this.playWithMpv(url, station.name, undefined, options.startPaused);
184
246
  try {
185
247
  await this.waitForReady(backend);
248
+ assertCurrent();
186
249
  }
187
250
  catch (error) {
251
+ assertCurrent();
188
252
  if (!this.shouldRetryMpvWithAlsa(error))
189
253
  throw error;
190
- await this.stop();
254
+ await this.stopPlayback();
255
+ assertCurrent();
191
256
  this.backend = backend;
192
257
  this.setState({
193
258
  backend,
@@ -199,21 +264,45 @@ export class PlayerController {
199
264
  streamUrl: url,
200
265
  ready: false
201
266
  });
202
- this.playWithMpv(url, station.name, 'alsa,');
267
+ this.playWithMpv(url, station.name, 'alsa,', options.startPaused);
203
268
  await this.waitForReady(backend);
269
+ assertCurrent();
204
270
  }
205
271
  }
206
272
  else if (backend === 'ffplay') {
207
- this.playWithFfplay(url);
208
- 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();
209
297
  }
210
298
  else if (backend === 'vlc') {
211
299
  this.playWithVlc(url);
212
300
  await this.waitForReady(backend);
301
+ assertCurrent();
213
302
  }
214
303
  this.setState({
215
304
  backend,
216
- state: 'playing',
305
+ state: options.startPaused ? 'paused' : 'playing',
217
306
  message: station.name,
218
307
  volume: this.getSettings().volume,
219
308
  muted: false,
@@ -279,27 +368,53 @@ export class PlayerController {
279
368
  const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null;
280
369
  if (!url)
281
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.' };
282
375
  this.mpvLiveRetuneInFlight = true;
376
+ this.mpvRetuneFailure = undefined;
283
377
  try {
284
378
  // Reloading the same URL discards mpv's delayed live buffer and opens a
285
379
  // fresh stream connection. This keeps radio pause/play semantics at the
286
380
  // live edge instead of behaving like time-shifted on-demand audio.
287
- await this.sendMpv({ command: ['loadfile', url, 'replace'] });
288
- await this.sendMpv({ command: ['set_property', 'pause', false] });
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.');
289
393
  }
290
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
+ }
291
399
  return { ok: false, message: 'mpv could not reconnect the paused station at the live edge.' };
292
400
  }
293
401
  finally {
294
- this.mpvLiveRetuneInFlight = false;
402
+ if (session === this.mpvSessionId)
403
+ this.mpvLiveRetuneInFlight = false;
295
404
  }
405
+ if (session !== this.mpvSessionId)
406
+ return { ok: false, message: 'Playback changed during resume.' };
296
407
  this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI';
297
408
  this.emitMetadata({ updatedAt: new Date().toISOString() });
298
409
  this.setState({ ...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString() });
299
410
  return { ok: true, message: 'Reconnected at the live broadcast.' };
300
411
  }
301
- setVolume(volume) {
412
+ setVolume(volume, options = {}) {
302
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
+ }
303
418
  const unsupported = this.unsupportedFfplayControl();
304
419
  if (unsupported) {
305
420
  return Promise.resolve(unsupported);
@@ -340,7 +455,11 @@ export class PlayerController {
340
455
  this.setState({ ...this.state, muted });
341
456
  return { ok: true };
342
457
  }
343
- async setMuted(muted) {
458
+ async setMuted(muted, options = {}) {
459
+ if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) {
460
+ this.setState({ ...this.state, muted });
461
+ return { ok: true };
462
+ }
344
463
  const unsupported = this.unsupportedFfplayControl();
345
464
  if (unsupported)
346
465
  return this.state.muted === muted ? { ok: true } : unsupported;
@@ -367,6 +486,10 @@ export class PlayerController {
367
486
  return { ok: true };
368
487
  }
369
488
  stop() {
489
+ this.playRequestId++;
490
+ return this.stopPlayback();
491
+ }
492
+ stopPlayback() {
370
493
  if (this.stopPromise)
371
494
  return this.stopPromise;
372
495
  const operation = this.performStop();
@@ -377,6 +500,7 @@ export class PlayerController {
377
500
  }
378
501
  async performStop() {
379
502
  const child = this.process;
503
+ this.mpvMediaIsLive = false;
380
504
  const startupInProgress = this.state.state === 'loading' && !this.state.ready;
381
505
  this.stopMpvPolling();
382
506
  this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
@@ -409,7 +533,11 @@ export class PlayerController {
409
533
  ready: false
410
534
  });
411
535
  }
536
+ airPlayPolicy() { return airPlayPolicy(this.getSettings(), this.runtime); }
412
537
  submitAirPlayPasscode(code) {
538
+ const policy = this.airPlayPolicy();
539
+ if (!policy.enabled)
540
+ return { ok: false, message: policy.message };
413
541
  if (this.backend !== 'airplay') {
414
542
  return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
415
543
  }
@@ -428,15 +556,34 @@ export class PlayerController {
428
556
  return { ok: true, message };
429
557
  }
430
558
  async refreshAirPlayDevices() {
431
- this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform });
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 });
432
563
  return [...this.availableAirPlayDevices];
433
564
  }
434
565
  detectedAirPlayDevices() {
435
566
  return [...this.availableAirPlayDevices];
436
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
+ }
437
584
  selectBackend() {
438
- const preferred = this.getSettings().preferredBackend;
439
- const backends = this.availableBackends ?? this.refreshDetectedBackends();
585
+ const preferred = this.effectivePreferredBackend();
586
+ const backends = this.availableBackends ? this.detectedBackends() : this.refreshDetectedBackends();
440
587
  if (preferred === 'mpv') {
441
588
  return backends.includes('mpv') ? 'mpv' : null;
442
589
  }
@@ -463,17 +610,17 @@ export class PlayerController {
463
610
  playbackUnavailableMessage() {
464
611
  const preferred = this.getSettings().preferredBackend;
465
612
  if (preferred === 'airplay') {
466
- if (!isAirPlayPlatformSupported(this.runtime.platform)) {
467
- return airPlayMacOSOnlyMessage;
468
- }
469
- 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}`;
470
617
  }
471
618
  if (preferred === 'mpv' || preferred === 'ffplay') {
472
619
  return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
473
620
  }
474
621
  return `No playback backend found. ${playbackBackendInstallHint()}`;
475
622
  }
476
- playWithMpv(url, initialTitle, recoveredAudioOutput) {
623
+ playWithMpv(url, initialTitle, recoveredAudioOutput, startPaused = false) {
477
624
  this.playbackSessionId += 1;
478
625
  this.ipcPath = mpvIpcPath();
479
626
  this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
@@ -481,10 +628,20 @@ export class PlayerController {
481
628
  this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
482
629
  this.pendingMpvVolume = null;
483
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 });
484
636
  this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
485
637
  '--no-video',
486
638
  '--msg-level=all=warn',
487
639
  '--force-window=no',
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,
488
645
  ...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
489
646
  ? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
490
647
  : []),
@@ -497,12 +654,13 @@ export class PlayerController {
497
654
  });
498
655
  this.wireProcess();
499
656
  }
500
- playWithFfplay(url) {
657
+ playWithFfplay(url, plainHttp = false) {
501
658
  this.playbackSessionId += 1;
502
- this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
659
+ this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-stats', ...ffmpegInputArguments(url, { plainHttp }), '-volume', String(this.getSettings().volume), '-autoexit', url], {
503
660
  stdio: ['pipe', 'pipe', 'pipe']
504
661
  });
505
662
  this.wireProcess();
663
+ return new Promise(resolve => this.process.once('close', resolve));
506
664
  }
507
665
  playWithVlc(url) {
508
666
  this.playbackSessionId += 1;
@@ -510,7 +668,8 @@ export class PlayerController {
510
668
  // binary (resolved from app bundles when off PATH) with a dummy interface.
511
669
  const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
512
670
  const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2);
513
- 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'] });
514
673
  this.wireProcess();
515
674
  }
516
675
  canRetuneAirPlay(device) {
@@ -593,7 +752,7 @@ export class PlayerController {
593
752
  throw new PlaybackOutputError('Selected AirPlay receiver was not found. Refresh AirPlay receivers in Settings.');
594
753
  }
595
754
  if (device.local) {
596
- 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.`);
597
756
  }
598
757
  return device;
599
758
  }
@@ -738,17 +897,48 @@ export class PlayerController {
738
897
  return;
739
898
  }
740
899
  this.lastPlayerExit = null;
900
+ this.activePlayerDiagnostic = '';
901
+ this.fallbackAudioStarted = false;
902
+ let progressBuffer = '';
903
+ let vlcAudioOutput = false;
904
+ let vlcDecoderStarted = false;
741
905
  let diagnostic = '';
906
+ let exitRecord;
742
907
  child.stdout.on('data', chunk => {
743
908
  diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
909
+ if (this.process === child)
910
+ this.activePlayerDiagnostic = diagnostic;
744
911
  });
745
912
  child.stderr.on('data', chunk => {
746
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
+ }
747
933
  });
748
934
  // Local players can write diagnostics indefinitely. Drain both pipes so a
749
935
  // full OS pipe buffer can never stall playback.
750
936
  child.stdout.resume?.();
751
937
  child.stderr.resume?.();
938
+ child.once('close', () => {
939
+ if (exitRecord && this.lastPlayerExit === exitRecord)
940
+ exitRecord.diagnostic = diagnostic;
941
+ });
752
942
  child.on('error', error => {
753
943
  diagnostic = appendPlayerDiagnostic(diagnostic, error.message);
754
944
  if (this.process !== child)
@@ -775,7 +965,11 @@ export class PlayerController {
775
965
  });
776
966
  child.on('exit', (code, signal) => {
777
967
  if (this.process === child) {
778
- this.lastPlayerExit = { backend, code, signal, diagnostic };
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 };
779
973
  this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
780
974
  if (this.backend === 'airplay') {
781
975
  this.currentAirPlayDevice = null;
@@ -789,8 +983,9 @@ export class PlayerController {
789
983
  this.setState({
790
984
  ...this.state,
791
985
  backend: this.backend ?? 'none',
792
- state: code === 0 || code === null ? 'stopped' : 'error',
793
- 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,
794
989
  ready: false
795
990
  });
796
991
  }
@@ -903,20 +1098,16 @@ export class PlayerController {
903
1098
  const wasSuperseded = () => this.playbackSessionId !== playbackSessionId;
904
1099
  const isCurrent = () => !wasSuperseded() && this.process === child;
905
1100
  while (Date.now() - started < timeoutMs) {
1101
+ if (backend === 'mpv' && this.mpvLiveRetuneInFlight && this.mpvRetuneFailure)
1102
+ throw new Error(this.mpvRetuneFailure);
906
1103
  if (wasSuperseded()) {
907
1104
  throw new Error(`${backend} playback request was superseded.`);
908
1105
  }
909
1106
  if (!child || this.process !== child) {
910
1107
  throw this.playerExitedBeforeReady(backend);
911
1108
  }
912
- if (backend === 'ffplay' || backend === 'vlc') {
913
- await waitForStartupWindow(() => this.process === child ? child : null, Math.min(500, timeoutMs), () => this.playerExitedBeforeReady(backend));
914
- if (wasSuperseded())
915
- throw new Error(`${backend} playback request was superseded.`);
916
- if (!isCurrent())
917
- throw this.playerExitedBeforeReady(backend);
1109
+ if ((backend === 'ffplay' || backend === 'vlc') && this.fallbackAudioStarted)
918
1110
  return;
919
- }
920
1111
  if (mpvClient) {
921
1112
  try {
922
1113
  await mpvClient.query({ command: ['get_property', 'path'] });
@@ -935,8 +1126,16 @@ export class PlayerController {
935
1126
  }
936
1127
  if (this.process !== child)
937
1128
  throw this.playerExitedBeforeReady(backend);
938
- await this.stop();
939
- throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
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);
940
1139
  }
941
1140
  shouldRetryMpvWithAlsa(error) {
942
1141
  return this.runtime.platform === 'linux' &&
@@ -951,6 +1150,9 @@ export class PlayerController {
951
1150
  ? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}`
952
1151
  : 'before its exit status was available';
953
1152
  const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? '');
1153
+ const inputFailure = backend === 'ffplay' ? ffmpegInputFailure(exit?.diagnostic ?? '') : undefined;
1154
+ if (inputFailure)
1155
+ return new PlaybackOutputError(inputFailure);
954
1156
  const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`;
955
1157
  return isBackendInitializationFailure(backend, exit, diagnostic)
956
1158
  ? new PlaybackOutputError(message)
@@ -961,10 +1163,65 @@ export class PlayerController {
961
1163
  client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
962
1164
  client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
963
1165
  ]);
964
- 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;
965
1180
  }
966
1181
  startMpvMetadataPolling() {
967
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);
968
1225
  this.metadataTimer = setInterval(() => {
969
1226
  if (!this.metadataPollInFlight)
970
1227
  void this.pollMpvMetadata();
@@ -975,8 +1232,11 @@ export class PlayerController {
975
1232
  }, 500);
976
1233
  void this.pollMpvMetadata();
977
1234
  void this.syncMpvPlaybackState();
1235
+ this.updateMpvStallTimer();
978
1236
  }
979
1237
  stopMpvPolling() {
1238
+ clearTimeout(this.mpvStallTimer);
1239
+ this.mpvStallTimer = undefined;
980
1240
  if (this.metadataTimer) {
981
1241
  clearInterval(this.metadataTimer);
982
1242
  this.metadataTimer = null;
@@ -986,27 +1246,59 @@ export class PlayerController {
986
1246
  this.playbackStateTimer = null;
987
1247
  }
988
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
+ }
989
1261
  async pollMpvMetadata() {
990
1262
  if (this.backend !== 'mpv' || !this.process) {
991
1263
  return;
992
1264
  }
1265
+ const client = this.mpvIpcClient;
1266
+ const session = this.mpvSessionId;
1267
+ if (!client)
1268
+ return;
993
1269
  this.metadataPollInFlight = true;
994
1270
  try {
995
- const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
996
- const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
997
- 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
+ }
998
1283
  this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
999
1284
  }
1000
- const title = extractMpvTitle(metadata);
1001
- if (title) {
1002
- await this.setMpvMediaTitle(title);
1003
- this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
1004
- }
1285
+ if (metadata)
1286
+ await this.applyMpvMetadata(metadata, client, session);
1005
1287
  }
1006
1288
  finally {
1007
- this.metadataPollInFlight = false;
1289
+ if (session === this.mpvSessionId)
1290
+ this.metadataPollInFlight = false;
1008
1291
  }
1009
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
+ }
1010
1302
  async setMpvMediaTitle(title) {
1011
1303
  const cleaned = cleanMediaTitle(title);
1012
1304
  if (!cleaned || cleaned === this.currentMpvMediaTitle) {
@@ -1021,25 +1313,35 @@ export class PlayerController {
1021
1313
  }
1022
1314
  if (this.playbackStatePollInFlight)
1023
1315
  return false;
1316
+ const client = this.mpvIpcClient;
1317
+ const session = this.mpvSessionId;
1318
+ if (!client)
1319
+ return false;
1024
1320
  this.playbackStatePollInFlight = true;
1025
1321
  try {
1026
- const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
1027
- 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) {
1028
1324
  return false;
1029
1325
  }
1030
- if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) {
1031
- return (await this.resumeMpvAtLiveEdge()).ok;
1032
- }
1033
- const state = paused ? 'paused' : 'playing';
1034
- if (this.state.state !== state) {
1035
- this.setState({ ...this.state, state });
1036
- }
1037
- return true;
1326
+ return this.applyMpvPause(paused);
1038
1327
  }
1039
1328
  finally {
1040
- this.playbackStatePollInFlight = false;
1329
+ if (session === this.mpvSessionId)
1330
+ this.playbackStatePollInFlight = false;
1041
1331
  }
1042
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
+ }
1043
1345
  emitMetadata(metadata) {
1044
1346
  for (const listener of this.metadataListeners) {
1045
1347
  try {
@@ -1056,6 +1358,11 @@ export class PlayerController {
1056
1358
  this.mpvIpcClient = null;
1057
1359
  this.pendingMpvVolume = null;
1058
1360
  this.mpvVolumeFlush = null;
1361
+ this.metadataPollInFlight = false;
1362
+ this.playbackStatePollInFlight = false;
1363
+ this.mpvLiveRetuneInFlight = false;
1364
+ this.mpvRetuneFailure = undefined;
1365
+ this.mpvObserved = false;
1059
1366
  if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
1060
1367
  try {
1061
1368
  unlinkSync(this.ipcPath);
@@ -1069,10 +1376,29 @@ export class PlayerController {
1069
1376
  }
1070
1377
  setState(state) {
1071
1378
  this.state = state;
1379
+ this.updateMpvStallTimer();
1072
1380
  for (const listener of this.listeners) {
1073
1381
  listener(state);
1074
1382
  }
1075
1383
  }
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();
1401
+ }
1076
1402
  }
1077
1403
  function airPlayWorkerPath() {
1078
1404
  const currentPath = fileURLToPath(import.meta.url);
@@ -1133,15 +1459,6 @@ export function extractMpvTitle(metadata) {
1133
1459
  }
1134
1460
  return undefined;
1135
1461
  }
1136
- async function waitForStartupWindow(getProcess, ms, exitedError = () => new Error('Player exited before the stream became ready.')) {
1137
- const started = Date.now();
1138
- while (Date.now() - started < ms) {
1139
- if (!getProcess()) {
1140
- throw exitedError();
1141
- }
1142
- await delay(Math.min(100, ms - (Date.now() - started)));
1143
- }
1144
- }
1145
1462
  function appendPlayerDiagnostic(current, chunk) {
1146
1463
  const combined = current + chunk;
1147
1464
  return combined.length <= maxPlayerDiagnosticCharacters