@ciphore/radiocli 0.1.2 → 0.1.4

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.
@@ -1,9 +1,23 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, unlinkSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { Socket } from 'node:net';
6
- import { detectPlaybackBackends, playbackBackendInstallHint } from './backend-install.js';
7
+ import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint } from './backend-install.js';
8
+ import { discoverAirPlayDevices } from './airplay-discovery.js';
9
+ import { airPlaySenderHealth } from './airplay-sender-health.js';
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
+ }
7
21
  export class PlayerController {
8
22
  getSettings;
9
23
  process = null;
@@ -15,6 +29,17 @@ export class PlayerController {
15
29
  listeners = new Set();
16
30
  metadataListeners = new Set();
17
31
  availableBackends = null;
32
+ availableAirPlayDevices = [];
33
+ airPlayReadyResolver = null;
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;
18
43
  nextMpvRequestId = 1;
19
44
  currentMpvMediaTitle = null;
20
45
  constructor(getSettings) {
@@ -54,11 +79,75 @@ export class PlayerController {
54
79
  return this.detectedBackends();
55
80
  }
56
81
  async play(station, url) {
57
- await this.stop();
58
82
  const backend = this.selectBackend();
59
83
  if (!backend) {
60
- throw new Error(`No playback backend found. ${playbackBackendInstallHint()}`);
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;
61
149
  }
150
+ await this.stop();
62
151
  this.backend = backend;
63
152
  this.setState({
64
153
  backend,
@@ -70,8 +159,14 @@ export class PlayerController {
70
159
  streamUrl: url,
71
160
  ready: false
72
161
  });
73
- backend === 'mpv' ? this.playWithMpv(url, station.name) : this.playWithFfplay(url);
74
- await this.waitForReady(backend);
162
+ if (backend === 'mpv') {
163
+ this.playWithMpv(url, station.name);
164
+ await this.waitForReady(backend);
165
+ }
166
+ else if (backend === 'ffplay') {
167
+ this.playWithFfplay(url);
168
+ await this.waitForReady(backend);
169
+ }
75
170
  this.setState({
76
171
  backend,
77
172
  state: 'playing',
@@ -89,56 +184,76 @@ export class PlayerController {
89
184
  }
90
185
  async togglePause() {
91
186
  if (!this.process || !this.backend || !this.state.ready) {
92
- return;
187
+ return { ok: false };
188
+ }
189
+ const unsupported = this.unsupportedFfplayControl();
190
+ if (unsupported) {
191
+ return unsupported;
93
192
  }
94
193
  if (this.backend === 'mpv') {
95
194
  await this.sendMpv({ command: ['cycle', 'pause'] }).catch(() => undefined);
96
195
  const synced = await this.syncMpvPlaybackState();
97
196
  if (synced) {
98
- return;
197
+ return { ok: true };
99
198
  }
100
199
  }
101
- else {
102
- this.process.stdin.write('p');
200
+ else if (this.backend === 'airplay') {
201
+ this.setState({ ...this.state, message: 'AirPlay pause is not supported. Use :stop to end playback.' });
202
+ return { ok: false, message: 'AirPlay pause is not supported. Use :stop to end playback.' };
103
203
  }
104
204
  this.setState({
105
205
  ...this.state,
106
206
  state: this.state.state === 'paused' ? 'playing' : 'paused'
107
207
  });
208
+ return { ok: true };
108
209
  }
109
210
  async setVolume(volume) {
110
211
  const clamped = clampVolume(volume);
212
+ const unsupported = this.unsupportedFfplayControl();
213
+ if (unsupported) {
214
+ return unsupported;
215
+ }
111
216
  if (this.backend === 'mpv') {
112
217
  await this.sendMpv({ command: ['set_property', 'volume', clamped] }).catch(() => undefined);
113
218
  }
114
- else if (this.backend === 'ffplay' && this.process) {
115
- const delta = clamped - this.state.volume;
116
- const key = delta > 0 ? '0' : '9';
117
- const steps = Math.min(10, Math.ceil(Math.abs(delta) / 5));
118
- for (let index = 0; index < steps; index += 1) {
119
- this.process.stdin.write(key);
120
- }
219
+ else if (this.backend === 'airplay') {
220
+ this.sendAirPlayCommand({ type: 'setVolume', volume: clamped });
121
221
  }
122
222
  this.setState({ ...this.state, volume: clamped });
223
+ return { ok: true };
123
224
  }
124
225
  async adjustVolume(delta) {
125
- await this.setVolume(this.state.volume + delta);
226
+ return this.setVolume(this.state.volume + delta);
126
227
  }
127
228
  async toggleMute() {
128
229
  const muted = !this.state.muted;
230
+ const unsupported = this.unsupportedFfplayControl();
231
+ if (unsupported) {
232
+ return unsupported;
233
+ }
129
234
  if (this.backend === 'mpv') {
130
235
  await this.sendMpv({ command: ['set_property', 'mute', muted] }).catch(() => undefined);
131
236
  }
132
- else if (this.backend === 'ffplay' && this.process) {
133
- this.process.stdin.write('m');
237
+ else if (this.backend === 'airplay') {
238
+ this.sendAirPlayCommand({ type: 'setMuted', muted });
134
239
  }
135
240
  this.setState({ ...this.state, muted });
241
+ return { ok: true };
136
242
  }
137
243
  async stop() {
138
244
  this.stopMpvPolling();
245
+ this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
246
+ this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
139
247
  if (this.backend === 'mpv') {
140
248
  await this.sendMpv({ command: ['quit'] }).catch(() => undefined);
141
249
  }
250
+ else if (this.backend === 'airplay') {
251
+ this.sendAirPlayCommand({ type: 'stop' });
252
+ this.currentAirPlayDevice = null;
253
+ this.currentAirPlayDeviceId = null;
254
+ this.pendingAirPlayPasscode = null;
255
+ this.airPlaySessionEstablished = false;
256
+ }
142
257
  if (this.process && !this.process.killed) {
143
258
  this.process.kill('SIGTERM');
144
259
  }
@@ -151,6 +266,31 @@ export class PlayerController {
151
266
  ready: false
152
267
  });
153
268
  }
269
+ submitAirPlayPasscode(code) {
270
+ if (this.backend !== 'airplay') {
271
+ return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
272
+ }
273
+ const trimmed = code.trim();
274
+ if (!trimmed || Buffer.byteLength(trimmed, 'utf8') > 64 || /[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) {
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.' };
281
+ }
282
+ this.pendingAirPlayPasscode = trimmed;
283
+ const message = 'AirPlay code sent.';
284
+ this.setState({ ...this.state, message });
285
+ return { ok: true, message };
286
+ }
287
+ async refreshAirPlayDevices() {
288
+ this.availableAirPlayDevices = await discoverAirPlayDevices();
289
+ return [...this.availableAirPlayDevices];
290
+ }
291
+ detectedAirPlayDevices() {
292
+ return [...this.availableAirPlayDevices];
293
+ }
154
294
  selectBackend() {
155
295
  const preferred = this.getSettings().preferredBackend;
156
296
  const backends = this.availableBackends ?? this.refreshDetectedBackends();
@@ -160,6 +300,9 @@ export class PlayerController {
160
300
  if (preferred === 'ffplay') {
161
301
  return backends.includes('ffplay') ? 'ffplay' : null;
162
302
  }
303
+ if (preferred === 'airplay') {
304
+ return backends.includes('airplay') ? 'airplay' : null;
305
+ }
163
306
  if (backends.includes('mpv')) {
164
307
  return 'mpv';
165
308
  }
@@ -168,6 +311,16 @@ export class PlayerController {
168
311
  }
169
312
  return null;
170
313
  }
314
+ playbackUnavailableMessage() {
315
+ const preferred = this.getSettings().preferredBackend;
316
+ if (preferred === 'airplay') {
317
+ return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
318
+ }
319
+ if (preferred === 'mpv' || preferred === 'ffplay') {
320
+ return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
321
+ }
322
+ return `No playback backend found. ${playbackBackendInstallHint()}`;
323
+ }
171
324
  playWithMpv(url, initialTitle) {
172
325
  this.ipcPath = createMpvIpcPath();
173
326
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
@@ -190,6 +343,220 @@ export class PlayerController {
190
343
  });
191
344
  this.wireProcess();
192
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
+ }
415
+ async resolveAirPlayDevice() {
416
+ const preferred = this.getSettings().preferredAirPlayDevice;
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);
422
+ if (!device) {
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.`);
427
+ }
428
+ return device;
429
+ }
430
+ playWithAirPlay(url, stationName, device) {
431
+ const workerPath = airPlayWorkerPath();
432
+ const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({
433
+ streamUrl: url,
434
+ stationName,
435
+ volume: this.getSettings().volume,
436
+ muted: false,
437
+ device
438
+ }));
439
+ this.process = spawn(process.execPath, workerArgs, {
440
+ stdio: ['pipe', 'pipe', 'pipe']
441
+ });
442
+ this.wireAirPlayProcess();
443
+ this.wireProcess();
444
+ const child = this.process;
445
+ return new Promise((resolve, reject) => {
446
+ const timeoutSeconds = this.airPlayTuneTimeoutSeconds();
447
+ const timeout = setTimeout(() => {
448
+ this.airPlayReadyResolver = null;
449
+ this.airPlayReadyRejecter = null;
450
+ const error = new PlaybackOutputError(`Timed out while opening AirPlay stream after ${timeoutSeconds}s.`);
451
+ this.stopAirPlayProcess(child);
452
+ this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: error.message });
453
+ reject(error);
454
+ }, timeoutSeconds * 1000);
455
+ this.airPlayReadyResolver = result => {
456
+ clearTimeout(timeout);
457
+ this.airPlayReadyResolver = null;
458
+ this.airPlayReadyRejecter = null;
459
+ resolve(result);
460
+ };
461
+ this.airPlayReadyRejecter = error => {
462
+ clearTimeout(timeout);
463
+ this.airPlayReadyResolver = null;
464
+ this.airPlayReadyRejecter = null;
465
+ reject(error);
466
+ };
467
+ });
468
+ }
469
+ wireAirPlayProcess() {
470
+ const child = this.process;
471
+ if (!child) {
472
+ return;
473
+ }
474
+ child.stderr.on('data', () => {
475
+ // node-airtunes2 is noisy during pairing; drain stderr so the worker cannot block.
476
+ });
477
+ let buffer = '';
478
+ child.stdout.on('data', chunk => {
479
+ buffer += chunk.toString('utf8');
480
+ let newlineIndex = buffer.indexOf('\n');
481
+ while (newlineIndex !== -1) {
482
+ const line = buffer.slice(0, newlineIndex);
483
+ buffer = buffer.slice(newlineIndex + 1);
484
+ newlineIndex = buffer.indexOf('\n');
485
+ const event = parseWorkerMessage(line);
486
+ if (event) {
487
+ this.handleAirPlayEvent(event);
488
+ }
489
+ }
490
+ });
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
+ }
505
+ handleAirPlayEvent(event) {
506
+ if (event.type === 'ready' || event.type === 'playing') {
507
+ if (this.airPlayRetuning) {
508
+ return;
509
+ }
510
+ this.airPlaySessionEstablished = true;
511
+ this.rememberPendingAirPlayPasscode();
512
+ this.airPlayReadyResolver?.('ready');
513
+ if (this.backend === 'airplay') {
514
+ this.setState({ ...this.state, backend: 'airplay', state: 'playing', ready: true, message: this.state.stationName });
515
+ }
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
+ }
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
+ }
543
+ this.airPlayReadyResolver?.('password-required');
544
+ this.setState({ ...this.state, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required. Use :airplay-code 1234.' });
545
+ }
546
+ else if (event.type === 'error') {
547
+ this.pendingAirPlayPasscode = null;
548
+ const error = new PlaybackOutputError(event.message);
549
+ this.airPlayReadyRejecter?.(error);
550
+ this.rejectPendingAirPlayRetune(error);
551
+ this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: event.message });
552
+ }
553
+ }
554
+ unsupportedFfplayControl() {
555
+ if (this.backend === 'ffplay' && this.process) {
556
+ return { ok: false, message: ffplayLimitedControlsMessage };
557
+ }
558
+ return null;
559
+ }
193
560
  wireProcess() {
194
561
  const child = this.process;
195
562
  if (!child) {
@@ -206,6 +573,13 @@ export class PlayerController {
206
573
  });
207
574
  child.on('exit', code => {
208
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
+ }
209
583
  this.process = null;
210
584
  this.stopMpvPolling();
211
585
  this.cleanupIpc();
@@ -222,6 +596,48 @@ export class PlayerController {
222
596
  sendMpv(payload) {
223
597
  return this.queryMpv(payload).then(() => undefined);
224
598
  }
599
+ sendAirPlayCommand(command) {
600
+ if (this.backend === 'airplay' && this.process && !this.process.killed) {
601
+ try {
602
+ this.process.stdin.write(serializeWorkerMessage(command));
603
+ return true;
604
+ }
605
+ catch {
606
+ return false;
607
+ }
608
+ }
609
+ return false;
610
+ }
611
+ stopAirPlayProcess(child) {
612
+ if (!child) {
613
+ return;
614
+ }
615
+ if (!child.killed) {
616
+ try {
617
+ child.stdin.write(serializeWorkerMessage({ type: 'stop' }));
618
+ }
619
+ catch {
620
+ // The worker may already be exiting.
621
+ }
622
+ child.kill('SIGTERM');
623
+ }
624
+ if (this.process === child) {
625
+ this.process = null;
626
+ }
627
+ }
628
+ rejectPendingAirPlayReady(error) {
629
+ const rejecter = this.airPlayReadyRejecter;
630
+ this.airPlayReadyResolver = null;
631
+ this.airPlayReadyRejecter = null;
632
+ rejecter?.(error);
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
+ }
225
641
  queryMpv(payload) {
226
642
  if (!this.ipcPath) {
227
643
  return Promise.resolve(null);
@@ -398,6 +814,17 @@ export function createMpvIpcPath(platform = process.platform, pid = process.pid,
398
814
  }
399
815
  return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
400
816
  }
817
+ function airPlayWorkerPath() {
818
+ const currentPath = fileURLToPath(import.meta.url);
819
+ const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js';
820
+ return join(dirname(currentPath), `airplay-worker${extension}`);
821
+ }
822
+ function airPlayWorkerArgs(workerPath, encodedStart) {
823
+ if (workerPath.endsWith('.ts')) {
824
+ return ['--import', 'tsx', workerPath, encodedStart];
825
+ }
826
+ return [workerPath, encodedStart];
827
+ }
401
828
  function isWindowsNamedPipePath(path) {
402
829
  return path.startsWith('\\\\.\\pipe\\');
403
830
  }
@@ -136,7 +136,8 @@ const settingsSchema = z.object({
136
136
  volume: z.number().min(0).max(100).default(70),
137
137
  enableRadioGarden: z.boolean().default(false),
138
138
  enableNearbyLocation: z.boolean().default(false),
139
- preferredBackend: z.enum(['auto', 'mpv', 'ffplay']).default('auto'),
139
+ preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'airplay']).default('auto'),
140
+ preferredAirPlayDevice: z.string().min(1).optional(),
140
141
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
141
142
  skipBrokenStreams: z.boolean().default(true),
142
143
  mediaKeys: z
@@ -294,7 +295,7 @@ export class JsonLibraryStore {
294
295
  }
295
296
  write() {
296
297
  mkdirSync(dirname(this.filePath), { recursive: true });
297
- writeJsonAtomically(this.filePath, this.state);
298
+ writeJsonAtomically(this.filePath, libraryStateForDisk(this.state));
298
299
  }
299
300
  }
300
301
  export function stationKey(station) {
@@ -344,18 +345,27 @@ function defaultState() {
344
345
  };
345
346
  }
346
347
  function migrateLibraryState(state) {
347
- if (state.settings.receiverStyleVersion === 2) {
348
- return state;
349
- }
350
348
  return {
351
349
  ...state,
352
350
  settings: {
353
351
  ...state.settings,
354
- receiverStyle: defaultReceiverStyle,
352
+ preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
353
+ receiverStyle: state.settings.receiverStyleVersion === 2 ? state.settings.receiverStyle : defaultReceiverStyle,
355
354
  receiverStyleVersion: 2
356
355
  }
357
356
  };
358
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
+ }
359
369
  function writeJsonAtomically(filePath, value) {
360
370
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
361
371
  try {