@ciphore/radiocli 0.1.3 → 0.1.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.
@@ -8,6 +8,16 @@ import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendIn
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
+ const minAirPlayTuneTimeoutSeconds = 30;
12
+ export class PlaybackOutputError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'PlaybackOutputError';
16
+ }
17
+ }
18
+ export function isPlaybackOutputError(error) {
19
+ return error instanceof PlaybackOutputError;
20
+ }
11
21
  export class PlayerController {
12
22
  getSettings;
13
23
  process = null;
@@ -22,6 +32,14 @@ export class PlayerController {
22
32
  availableAirPlayDevices = [];
23
33
  airPlayReadyResolver = null;
24
34
  airPlayReadyRejecter = null;
35
+ airPlayRetuneResolver = null;
36
+ airPlayRetuneRejecter = null;
37
+ airPlayRetuning = false;
38
+ airPlaySessionEstablished = false;
39
+ airPlayPasscodes = new Map();
40
+ currentAirPlayDevice = null;
41
+ currentAirPlayDeviceId = null;
42
+ pendingAirPlayPasscode = null;
25
43
  nextMpvRequestId = 1;
26
44
  currentMpvMediaTitle = null;
27
45
  constructor(getSettings) {
@@ -61,13 +79,76 @@ export class PlayerController {
61
79
  return this.detectedBackends();
62
80
  }
63
81
  async play(station, url) {
64
- await this.stop();
65
82
  const backend = this.selectBackend();
66
83
  if (!backend) {
67
- throw new Error(this.playbackUnavailableMessage());
84
+ await this.stop();
85
+ throw new PlaybackOutputError(this.playbackUnavailableMessage());
86
+ }
87
+ if (backend === 'airplay') {
88
+ const activeDevice = this.activeAirPlayDeviceForRetune();
89
+ if (activeDevice) {
90
+ await this.retuneAirPlay(url, station.name, activeDevice);
91
+ return;
92
+ }
93
+ let device;
94
+ try {
95
+ device = await this.resolveAirPlayDevice();
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : 'Could not resolve AirPlay receiver.';
99
+ this.setState({ ...this.state, backend, state: 'error', message, ready: false });
100
+ throw error;
101
+ }
102
+ if (this.canRetuneAirPlay(device)) {
103
+ await this.retuneAirPlay(url, station.name, device);
104
+ return;
105
+ }
106
+ await this.stop();
107
+ this.backend = backend;
108
+ this.currentAirPlayDevice = device;
109
+ this.currentAirPlayDeviceId = device.id;
110
+ this.setState({
111
+ backend,
112
+ state: 'loading',
113
+ message: `Opening ${station.name}`,
114
+ volume: this.getSettings().volume,
115
+ muted: false,
116
+ stationName: station.name,
117
+ streamUrl: url,
118
+ ready: false
119
+ });
120
+ this.setState({ ...this.state, airPlayDeviceName: device.name });
121
+ const result = await this.playWithAirPlay(url, station.name, device);
122
+ if (result === 'password-required') {
123
+ if (this.pendingAirPlayPasscode) {
124
+ return;
125
+ }
126
+ this.setState({
127
+ ...this.state,
128
+ backend,
129
+ state: 'loading',
130
+ message: 'AirPlay code required. Use :airplay-code 1234.',
131
+ ready: false
132
+ });
133
+ return;
134
+ }
135
+ this.airPlaySessionEstablished = true;
136
+ this.setState({
137
+ backend,
138
+ state: 'playing',
139
+ message: station.name,
140
+ volume: this.getSettings().volume,
141
+ muted: false,
142
+ stationName: station.name,
143
+ airPlayDeviceName: device.name,
144
+ streamUrl: url,
145
+ startedAt: new Date().toISOString(),
146
+ ready: true
147
+ });
148
+ return;
68
149
  }
150
+ await this.stop();
69
151
  this.backend = backend;
70
- let airPlayDeviceName;
71
152
  this.setState({
72
153
  backend,
73
154
  state: 'loading',
@@ -86,22 +167,6 @@ export class PlayerController {
86
167
  this.playWithFfplay(url);
87
168
  await this.waitForReady(backend);
88
169
  }
89
- else {
90
- const device = await this.resolveAirPlayDevice();
91
- airPlayDeviceName = device.name;
92
- this.setState({ ...this.state, airPlayDeviceName });
93
- const result = await this.playWithAirPlay(url, station.name, device);
94
- if (result === 'password-required') {
95
- this.setState({
96
- ...this.state,
97
- backend,
98
- state: 'loading',
99
- message: 'AirPlay code required. Use :airplay-code 1234.',
100
- ready: false
101
- });
102
- return;
103
- }
104
- }
105
170
  this.setState({
106
171
  backend,
107
172
  state: 'playing',
@@ -109,7 +174,6 @@ export class PlayerController {
109
174
  volume: this.getSettings().volume,
110
175
  muted: false,
111
176
  stationName: station.name,
112
- airPlayDeviceName,
113
177
  streamUrl: url,
114
178
  startedAt: new Date().toISOString(),
115
179
  ready: true
@@ -179,11 +243,16 @@ export class PlayerController {
179
243
  async stop() {
180
244
  this.stopMpvPolling();
181
245
  this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
246
+ this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
182
247
  if (this.backend === 'mpv') {
183
248
  await this.sendMpv({ command: ['quit'] }).catch(() => undefined);
184
249
  }
185
250
  else if (this.backend === 'airplay') {
186
251
  this.sendAirPlayCommand({ type: 'stop' });
252
+ this.currentAirPlayDevice = null;
253
+ this.currentAirPlayDeviceId = null;
254
+ this.pendingAirPlayPasscode = null;
255
+ this.airPlaySessionEstablished = false;
187
256
  }
188
257
  if (this.process && !this.process.killed) {
189
258
  this.process.kill('SIGTERM');
@@ -199,15 +268,21 @@ export class PlayerController {
199
268
  }
200
269
  submitAirPlayPasscode(code) {
201
270
  if (this.backend !== 'airplay') {
202
- return;
271
+ return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
203
272
  }
204
273
  const trimmed = code.trim();
205
274
  if (!trimmed || Buffer.byteLength(trimmed, 'utf8') > 64 || /[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) {
206
- this.setState({ ...this.state, message: 'AirPlay code must be 1-64 printable characters.' });
207
- return;
275
+ const message = 'AirPlay code must be 1-64 printable characters.';
276
+ this.setState({ ...this.state, message });
277
+ return { ok: false, message };
278
+ }
279
+ if (!this.sendAirPlayCommand({ type: 'passcode', code: trimmed })) {
280
+ return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
208
281
  }
209
- this.sendAirPlayCommand({ type: 'passcode', code: trimmed });
210
- this.setState({ ...this.state, message: 'AirPlay code sent.' });
282
+ this.pendingAirPlayPasscode = trimmed;
283
+ const message = 'AirPlay code sent.';
284
+ this.setState({ ...this.state, message });
285
+ return { ok: true, message };
211
286
  }
212
287
  async refreshAirPlayDevices() {
213
288
  this.availableAirPlayDevices = await discoverAirPlayDevices();
@@ -234,15 +309,12 @@ export class PlayerController {
234
309
  if (backends.includes('ffplay')) {
235
310
  return 'ffplay';
236
311
  }
237
- if (backends.includes('airplay')) {
238
- return 'airplay';
239
- }
240
312
  return null;
241
313
  }
242
314
  playbackUnavailableMessage() {
243
315
  const preferred = this.getSettings().preferredBackend;
244
316
  if (preferred === 'airplay') {
245
- return `AirPlay backend unavailable. It requires macOS, ffmpeg, dns-sd, and a sender package that passes RadioCLI's dependency safety gate. ${airPlaySenderHealth().message}`;
317
+ return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
246
318
  }
247
319
  if (preferred === 'mpv' || preferred === 'ffplay') {
248
320
  return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
@@ -271,12 +343,87 @@ export class PlayerController {
271
343
  });
272
344
  this.wireProcess();
273
345
  }
346
+ canRetuneAirPlay(device) {
347
+ return Boolean(this.backend === 'airplay' &&
348
+ this.process &&
349
+ !this.process.killed &&
350
+ this.airPlaySessionEstablished &&
351
+ this.currentAirPlayDeviceId === device.id);
352
+ }
353
+ activeAirPlayDeviceForRetune() {
354
+ const preferred = this.getSettings().preferredAirPlayDevice;
355
+ if (!this.currentAirPlayDevice || this.currentAirPlayDevice.id !== preferred || !this.canRetuneAirPlay(this.currentAirPlayDevice)) {
356
+ return null;
357
+ }
358
+ return this.currentAirPlayDevice;
359
+ }
360
+ retuneAirPlay(url, stationName, device) {
361
+ this.rejectPendingAirPlayRetune(new Error('AirPlay retune superseded.'));
362
+ this.airPlayRetuning = true;
363
+ this.setState({
364
+ ...this.state,
365
+ backend: 'airplay',
366
+ state: 'loading',
367
+ message: `Opening ${stationName}`,
368
+ stationName,
369
+ airPlayDeviceName: device.name,
370
+ streamUrl: url,
371
+ ready: false
372
+ });
373
+ if (!this.sendAirPlayCommand({ type: 'retune', streamUrl: url, stationName })) {
374
+ this.airPlayRetuning = false;
375
+ throw new PlaybackOutputError('AirPlay session is not available for retuning.');
376
+ }
377
+ return new Promise((resolve, reject) => {
378
+ const timeoutSeconds = this.airPlayTuneTimeoutSeconds();
379
+ const timeout = setTimeout(() => {
380
+ this.airPlayRetuneResolver = null;
381
+ this.airPlayRetuneRejecter = null;
382
+ this.airPlayRetuning = false;
383
+ const error = new PlaybackOutputError(`Timed out while switching AirPlay stream after ${timeoutSeconds}s. The receiver is still connected — pick another station or :stop.`);
384
+ // Keep the worker (and the paired receiver) alive so the next switch stays instant.
385
+ this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: error.message });
386
+ reject(error);
387
+ }, timeoutSeconds * 1000);
388
+ this.airPlayRetuneResolver = () => {
389
+ clearTimeout(timeout);
390
+ this.airPlayRetuneResolver = null;
391
+ this.airPlayRetuneRejecter = null;
392
+ this.airPlayRetuning = false;
393
+ this.setState({
394
+ ...this.state,
395
+ backend: 'airplay',
396
+ state: 'playing',
397
+ message: stationName,
398
+ stationName,
399
+ airPlayDeviceName: device.name,
400
+ streamUrl: url,
401
+ startedAt: new Date().toISOString(),
402
+ ready: true
403
+ });
404
+ resolve();
405
+ };
406
+ this.airPlayRetuneRejecter = error => {
407
+ clearTimeout(timeout);
408
+ this.airPlayRetuneResolver = null;
409
+ this.airPlayRetuneRejecter = null;
410
+ this.airPlayRetuning = false;
411
+ reject(error);
412
+ };
413
+ });
414
+ }
274
415
  async resolveAirPlayDevice() {
275
- const devices = await this.refreshAirPlayDevices();
276
416
  const preferred = this.getSettings().preferredAirPlayDevice;
277
- const device = devices.find(candidate => candidate.id === preferred) ?? devices[0];
417
+ if (!preferred) {
418
+ throw new PlaybackOutputError('Choose an AirPlay receiver in Settings before tuning with AirPlay.');
419
+ }
420
+ const devices = await this.refreshAirPlayDevices();
421
+ const device = devices.find(candidate => candidate.id === preferred);
278
422
  if (!device) {
279
- throw new Error('No AirPlay receiver found. Make sure the receiver is on the same network.');
423
+ throw new PlaybackOutputError('Selected AirPlay receiver was not found. Refresh AirPlay receivers in Settings.');
424
+ }
425
+ if (device.local) {
426
+ throw new PlaybackOutputError(`${device.name} is this Mac. Use Audio output: This device instead of AirPlay.`);
280
427
  }
281
428
  return device;
282
429
  }
@@ -296,14 +443,15 @@ export class PlayerController {
296
443
  this.wireProcess();
297
444
  const child = this.process;
298
445
  return new Promise((resolve, reject) => {
446
+ const timeoutSeconds = this.airPlayTuneTimeoutSeconds();
299
447
  const timeout = setTimeout(() => {
300
448
  this.airPlayReadyResolver = null;
301
449
  this.airPlayReadyRejecter = null;
302
- const error = new Error(`Timed out while opening AirPlay stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
450
+ const error = new PlaybackOutputError(`Timed out while opening AirPlay stream after ${timeoutSeconds}s.`);
303
451
  this.stopAirPlayProcess(child);
304
452
  this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: error.message });
305
453
  reject(error);
306
- }, this.getSettings().tuneTimeoutSeconds * 1000);
454
+ }, timeoutSeconds * 1000);
307
455
  this.airPlayReadyResolver = result => {
308
456
  clearTimeout(timeout);
309
457
  this.airPlayReadyResolver = null;
@@ -323,6 +471,9 @@ export class PlayerController {
323
471
  if (!child) {
324
472
  return;
325
473
  }
474
+ child.stderr.on('data', () => {
475
+ // node-airtunes2 is noisy during pairing; drain stderr so the worker cannot block.
476
+ });
326
477
  let buffer = '';
327
478
  child.stdout.on('data', chunk => {
328
479
  buffer += chunk.toString('utf8');
@@ -338,20 +489,65 @@ export class PlayerController {
338
489
  }
339
490
  });
340
491
  }
492
+ airPlayTuneTimeoutSeconds() {
493
+ const configured = this.getSettings().tuneTimeoutSeconds;
494
+ return configured < 3 ? configured : Math.max(configured, minAirPlayTuneTimeoutSeconds);
495
+ }
496
+ cachedAirPlayPasscode() {
497
+ return this.currentAirPlayDeviceId ? this.airPlayPasscodes.get(this.currentAirPlayDeviceId) ?? null : null;
498
+ }
499
+ rememberPendingAirPlayPasscode() {
500
+ if (this.currentAirPlayDeviceId && this.pendingAirPlayPasscode) {
501
+ this.airPlayPasscodes.set(this.currentAirPlayDeviceId, this.pendingAirPlayPasscode);
502
+ this.pendingAirPlayPasscode = null;
503
+ }
504
+ }
341
505
  handleAirPlayEvent(event) {
342
506
  if (event.type === 'ready' || event.type === 'playing') {
507
+ if (this.airPlayRetuning) {
508
+ return;
509
+ }
510
+ this.airPlaySessionEstablished = true;
511
+ this.rememberPendingAirPlayPasscode();
343
512
  this.airPlayReadyResolver?.('ready');
344
513
  if (this.backend === 'airplay') {
345
514
  this.setState({ ...this.state, backend: 'airplay', state: 'playing', ready: true, message: this.state.stationName });
346
515
  }
347
516
  }
517
+ else if (event.type === 'retuned') {
518
+ if (this.airPlayRetuneResolver) {
519
+ this.airPlayRetuneResolver();
520
+ }
521
+ else if (this.backend === 'airplay' && this.currentAirPlayDevice && this.state.state !== 'playing') {
522
+ // A retune that already timed out on our side eventually caught up; reflect live playback.
523
+ this.airPlayRetuning = false;
524
+ this.airPlaySessionEstablished = true;
525
+ this.setState({
526
+ ...this.state,
527
+ backend: 'airplay',
528
+ state: 'playing',
529
+ ready: true,
530
+ message: this.state.stationName,
531
+ startedAt: new Date().toISOString()
532
+ });
533
+ }
534
+ }
348
535
  else if (event.type === 'password-required') {
536
+ const passcode = this.cachedAirPlayPasscode();
537
+ if (passcode) {
538
+ this.pendingAirPlayPasscode = passcode;
539
+ this.sendAirPlayCommand({ type: 'passcode', code: passcode });
540
+ this.setState({ ...this.state, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code sent.' });
541
+ return;
542
+ }
349
543
  this.airPlayReadyResolver?.('password-required');
350
544
  this.setState({ ...this.state, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required. Use :airplay-code 1234.' });
351
545
  }
352
546
  else if (event.type === 'error') {
353
- const error = new Error(event.message);
547
+ this.pendingAirPlayPasscode = null;
548
+ const error = new PlaybackOutputError(event.message);
354
549
  this.airPlayReadyRejecter?.(error);
550
+ this.rejectPendingAirPlayRetune(error);
355
551
  this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: event.message });
356
552
  }
357
553
  }
@@ -377,6 +573,13 @@ export class PlayerController {
377
573
  });
378
574
  child.on('exit', code => {
379
575
  if (this.process === child) {
576
+ this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
577
+ if (this.backend === 'airplay') {
578
+ this.currentAirPlayDevice = null;
579
+ this.currentAirPlayDeviceId = null;
580
+ this.pendingAirPlayPasscode = null;
581
+ this.airPlaySessionEstablished = false;
582
+ }
380
583
  this.process = null;
381
584
  this.stopMpvPolling();
382
585
  this.cleanupIpc();
@@ -395,8 +598,15 @@ export class PlayerController {
395
598
  }
396
599
  sendAirPlayCommand(command) {
397
600
  if (this.backend === 'airplay' && this.process && !this.process.killed) {
398
- this.process.stdin.write(serializeWorkerMessage(command));
601
+ try {
602
+ this.process.stdin.write(serializeWorkerMessage(command));
603
+ return true;
604
+ }
605
+ catch {
606
+ return false;
607
+ }
399
608
  }
609
+ return false;
400
610
  }
401
611
  stopAirPlayProcess(child) {
402
612
  if (!child) {
@@ -421,6 +631,13 @@ export class PlayerController {
421
631
  this.airPlayReadyRejecter = null;
422
632
  rejecter?.(error);
423
633
  }
634
+ rejectPendingAirPlayRetune(error) {
635
+ const rejecter = this.airPlayRetuneRejecter;
636
+ this.airPlayRetuneResolver = null;
637
+ this.airPlayRetuneRejecter = null;
638
+ this.airPlayRetuning = false;
639
+ rejecter?.(error);
640
+ }
424
641
  queryMpv(payload) {
425
642
  if (!this.ipcPath) {
426
643
  return Promise.resolve(null);
@@ -295,7 +295,7 @@ export class JsonLibraryStore {
295
295
  }
296
296
  write() {
297
297
  mkdirSync(dirname(this.filePath), { recursive: true });
298
- writeJsonAtomically(this.filePath, this.state);
298
+ writeJsonAtomically(this.filePath, libraryStateForDisk(this.state));
299
299
  }
300
300
  }
301
301
  export function stationKey(station) {
@@ -345,18 +345,27 @@ function defaultState() {
345
345
  };
346
346
  }
347
347
  function migrateLibraryState(state) {
348
- if (state.settings.receiverStyleVersion === 2) {
349
- return state;
350
- }
351
348
  return {
352
349
  ...state,
353
350
  settings: {
354
351
  ...state.settings,
355
- receiverStyle: defaultReceiverStyle,
352
+ preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
353
+ receiverStyle: state.settings.receiverStyleVersion === 2 ? state.settings.receiverStyle : defaultReceiverStyle,
356
354
  receiverStyleVersion: 2
357
355
  }
358
356
  };
359
357
  }
358
+ function libraryStateForDisk(state) {
359
+ return state.settings.preferredBackend === 'airplay'
360
+ ? {
361
+ ...state,
362
+ settings: {
363
+ ...state.settings,
364
+ preferredBackend: 'auto'
365
+ }
366
+ }
367
+ : state;
368
+ }
360
369
  function writeJsonAtomically(filePath, value) {
361
370
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
362
371
  try {