@ciphore/radiocli 0.1.1 → 0.1.3

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.
@@ -0,0 +1,178 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { airPlaySenderHealth } from './airplay-sender-health.js';
4
+ import { parseWorkerMessage, decodeWorkerStart, maxWorkerMessageBytes, serializeWorkerMessage } from './airplay-worker-protocol.js';
5
+ const require = createRequire(import.meta.url);
6
+ // node-airtunes2 writes diagnostics with console.log; keep stdout reserved for JSON events.
7
+ console.log = (...args) => {
8
+ process.stderr.write(`${args.map(String).join(' ')}\n`);
9
+ };
10
+ const encodedStart = process.argv[2];
11
+ if (!encodedStart) {
12
+ emit({ type: 'error', message: 'Missing AirPlay worker start payload.' });
13
+ process.exit(1);
14
+ }
15
+ const AirTunes = loadAirTunesSender();
16
+ const start = decodeStartPayload(encodedStart);
17
+ const airtunes = new AirTunes();
18
+ let deviceKey = '';
19
+ let muted = start.muted;
20
+ let volume = start.volume;
21
+ let stopping = false;
22
+ airtunes.on('device', (key, status) => {
23
+ if (typeof key === 'string') {
24
+ deviceKey = key;
25
+ }
26
+ if (status === 'ready') {
27
+ emit({ type: 'ready' });
28
+ }
29
+ else if (status === 'playing' || status === 'pair_success') {
30
+ emit({ type: 'playing' });
31
+ }
32
+ else if (status === 'need_password') {
33
+ emit({ type: 'password-required' });
34
+ }
35
+ else if (status === 'stopped') {
36
+ emit({ type: 'stopped' });
37
+ }
38
+ else if (status === 'error') {
39
+ emit({ type: 'error', message: 'AirPlay receiver reported an error.' });
40
+ }
41
+ });
42
+ airtunes.on('buffer', status => {
43
+ emit({ type: 'buffer', status: String(status) });
44
+ if (status === 'playing') {
45
+ emit({ type: 'playing' });
46
+ }
47
+ });
48
+ airtunes.on('error', error => {
49
+ emit({ type: 'error', message: error instanceof Error ? error.message : String(error) });
50
+ });
51
+ const device = airtunes.add(start.device.host, {
52
+ port: start.device.port,
53
+ volume: muted ? 0 : volume,
54
+ txt: start.device.txt,
55
+ airplay2: start.device.airplay2,
56
+ forceAlac: true,
57
+ debug: false,
58
+ mode: start.device.airplay2 ? 2 : 0
59
+ });
60
+ deviceKey = device.key;
61
+ const ffmpeg = spawn('ffmpeg', [
62
+ '-hide_banner',
63
+ '-loglevel',
64
+ 'error',
65
+ '-i',
66
+ start.streamUrl,
67
+ '-f',
68
+ 's16le',
69
+ '-ac',
70
+ '2',
71
+ '-ar',
72
+ '44100',
73
+ 'pipe:1'
74
+ ], {
75
+ stdio: ['ignore', 'pipe', 'pipe']
76
+ });
77
+ ffmpeg.stdout.on('data', chunk => {
78
+ airtunes.write(chunk);
79
+ });
80
+ ffmpeg.stderr.on('data', chunk => {
81
+ process.stderr.write(chunk);
82
+ });
83
+ ffmpeg.once('error', error => {
84
+ emit({ type: 'error', message: error.message });
85
+ stop(1);
86
+ });
87
+ ffmpeg.once('exit', code => {
88
+ if (!stopping) {
89
+ if (code !== 0) {
90
+ emit({ type: 'error', message: `ffmpeg exited with code ${code}` });
91
+ }
92
+ stop(code ?? 0);
93
+ }
94
+ });
95
+ let stdinBuffer = '';
96
+ process.stdin.on('data', chunk => {
97
+ stdinBuffer += chunk.toString('utf8');
98
+ if (Buffer.byteLength(stdinBuffer, 'utf8') > maxWorkerMessageBytes) {
99
+ emit({ type: 'error', message: 'AirPlay worker command exceeded the size limit.' });
100
+ stdinBuffer = '';
101
+ return;
102
+ }
103
+ let newlineIndex = stdinBuffer.indexOf('\n');
104
+ while (newlineIndex !== -1) {
105
+ const line = stdinBuffer.slice(0, newlineIndex);
106
+ stdinBuffer = stdinBuffer.slice(newlineIndex + 1);
107
+ newlineIndex = stdinBuffer.indexOf('\n');
108
+ const command = parseWorkerMessage(line);
109
+ if (command) {
110
+ handleCommand(command);
111
+ }
112
+ }
113
+ });
114
+ process.once('SIGTERM', () => stop(0));
115
+ process.once('SIGINT', () => stop(0));
116
+ function handleCommand(command) {
117
+ if (command.type === 'stop') {
118
+ stop(0);
119
+ }
120
+ else if (command.type === 'setVolume') {
121
+ volume = clampVolume(command.volume);
122
+ setAirPlayVolume(muted ? 0 : volume);
123
+ }
124
+ else if (command.type === 'setMuted') {
125
+ muted = command.muted;
126
+ setAirPlayVolume(muted ? 0 : volume);
127
+ }
128
+ else if (command.type === 'passcode') {
129
+ device.setPasscode?.(command.code);
130
+ }
131
+ }
132
+ function setAirPlayVolume(nextVolume) {
133
+ if (deviceKey) {
134
+ airtunes.setVolume(deviceKey, nextVolume);
135
+ }
136
+ }
137
+ function emit(event) {
138
+ process.stdout.write(serializeWorkerMessage(event));
139
+ }
140
+ function loadAirTunesSender() {
141
+ const health = airPlaySenderHealth();
142
+ if (!health.safe) {
143
+ emit({ type: 'error', message: health.message });
144
+ process.exit(1);
145
+ }
146
+ try {
147
+ return require('node-airtunes2');
148
+ }
149
+ catch {
150
+ emit({ type: 'error', message: 'AirPlay sender package could not be loaded after passing the safety gate.' });
151
+ process.exit(1);
152
+ }
153
+ }
154
+ function decodeStartPayload(encoded) {
155
+ try {
156
+ return decodeWorkerStart(encoded);
157
+ }
158
+ catch (error) {
159
+ emit({ type: 'error', message: error instanceof Error ? error.message : 'Invalid AirPlay worker start payload.' });
160
+ process.exit(1);
161
+ }
162
+ }
163
+ function stop(code) {
164
+ if (stopping) {
165
+ return;
166
+ }
167
+ stopping = true;
168
+ if (!ffmpeg.killed) {
169
+ ffmpeg.kill('SIGTERM');
170
+ }
171
+ airtunes.stopAll(() => {
172
+ emit({ type: 'stopped' });
173
+ process.exit(code);
174
+ });
175
+ }
176
+ function clampVolume(nextVolume) {
177
+ return Math.min(100, Math.max(0, Math.round(nextVolume)));
178
+ }
@@ -1,8 +1,59 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { commandExists } from './command.js';
3
- const playbackBackends = ['mpv', 'ffplay'];
4
- export function detectPlaybackBackends() {
5
- return playbackBackends.filter(commandExists);
3
+ import { airPlaySenderHealth } from './airplay-sender-health.js';
4
+ export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
5
+ export function detectPlaybackBackends({ platform = process.platform, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
6
+ const backends = ['mpv', 'ffplay'].filter(hasCommand);
7
+ if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) {
8
+ backends.push('airplay');
9
+ }
10
+ return backends;
11
+ }
12
+ function hasAirPlaySenderPackage() {
13
+ return airPlaySenderHealth().safe;
14
+ }
15
+ export function playbackBackendCapabilities(backend) {
16
+ if (backend === 'mpv') {
17
+ return {
18
+ backend,
19
+ label: 'mpv',
20
+ supportsPause: true,
21
+ supportsMute: true,
22
+ supportsVolume: true,
23
+ supportsMediaKeys: true
24
+ };
25
+ }
26
+ if (backend === 'ffplay') {
27
+ return {
28
+ backend,
29
+ label: 'ffplay fallback',
30
+ supportsPause: false,
31
+ supportsMute: false,
32
+ supportsVolume: false,
33
+ supportsMediaKeys: false
34
+ };
35
+ }
36
+ if (backend === 'airplay') {
37
+ return {
38
+ backend,
39
+ label: 'AirPlay',
40
+ supportsPause: false,
41
+ supportsMute: true,
42
+ supportsVolume: true,
43
+ supportsMediaKeys: false
44
+ };
45
+ }
46
+ return {
47
+ backend: backend ?? 'none',
48
+ label: backend || 'no backend',
49
+ supportsPause: false,
50
+ supportsMute: false,
51
+ supportsVolume: false,
52
+ supportsMediaKeys: false
53
+ };
54
+ }
55
+ export function playbackBackendLabel(backend) {
56
+ return playbackBackendCapabilities(backend).label;
6
57
  }
7
58
  export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease()) {
8
59
  return `Install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`;
@@ -10,7 +61,7 @@ export function playbackBackendInstallHint(platform = process.platform, osReleas
10
61
  export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease()) {
11
62
  const backendSet = new Set(backends);
12
63
  const lines = [
13
- 'npm_install=RadioCLI only; native playback comes from mpv or ffplay',
64
+ 'npm_install=RadioCLI only; native playback comes from mpv, ffplay, or AirPlay prerequisites',
14
65
  `install_mpv=${mpvInstallCommand(platform, osRelease)}`,
15
66
  `optional_ffplay=${ffplayInstallCommand(platform, osRelease)}`
16
67
  ];
@@ -18,6 +69,7 @@ export function playbackBackendStatusLines(backends, platform = process.platform
18
69
  return [
19
70
  'playback=ready',
20
71
  'playback_backend=mpv',
72
+ 'controls=full',
21
73
  ...lines
22
74
  ];
23
75
  }
@@ -25,12 +77,25 @@ export function playbackBackendStatusLines(backends, platform = process.platform
25
77
  return [
26
78
  'playback=fallback-only',
27
79
  'playback_backend=ffplay',
80
+ 'controls=limited',
81
+ 'controls_hint=install mpv for pause, mute, volume, and media keys',
82
+ ...lines
83
+ ];
84
+ }
85
+ if (backendSet.has('airplay')) {
86
+ return [
87
+ 'playback=airplay-only',
88
+ 'playback_backend=airplay',
89
+ 'controls=airplay-limited',
90
+ 'controls_hint=AirPlay supports volume and mute; pause is not supported',
28
91
  ...lines
29
92
  ];
30
93
  }
31
94
  return [
32
95
  'playback=missing',
33
96
  'playback_backend=none',
97
+ 'controls=missing',
98
+ 'controls_hint=install mpv for playback and controls',
34
99
  ...lines
35
100
  ];
36
101
  }
@@ -1,9 +1,13 @@
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';
7
11
  export class PlayerController {
8
12
  getSettings;
9
13
  process = null;
@@ -15,6 +19,9 @@ export class PlayerController {
15
19
  listeners = new Set();
16
20
  metadataListeners = new Set();
17
21
  availableBackends = null;
22
+ availableAirPlayDevices = [];
23
+ airPlayReadyResolver = null;
24
+ airPlayReadyRejecter = null;
18
25
  nextMpvRequestId = 1;
19
26
  currentMpvMediaTitle = null;
20
27
  constructor(getSettings) {
@@ -57,9 +64,10 @@ export class PlayerController {
57
64
  await this.stop();
58
65
  const backend = this.selectBackend();
59
66
  if (!backend) {
60
- throw new Error(`No playback backend found. ${playbackBackendInstallHint()}`);
67
+ throw new Error(this.playbackUnavailableMessage());
61
68
  }
62
69
  this.backend = backend;
70
+ let airPlayDeviceName;
63
71
  this.setState({
64
72
  backend,
65
73
  state: 'loading',
@@ -70,8 +78,30 @@ export class PlayerController {
70
78
  streamUrl: url,
71
79
  ready: false
72
80
  });
73
- backend === 'mpv' ? this.playWithMpv(url, station.name) : this.playWithFfplay(url);
74
- await this.waitForReady(backend);
81
+ if (backend === 'mpv') {
82
+ this.playWithMpv(url, station.name);
83
+ await this.waitForReady(backend);
84
+ }
85
+ else if (backend === 'ffplay') {
86
+ this.playWithFfplay(url);
87
+ await this.waitForReady(backend);
88
+ }
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
+ }
75
105
  this.setState({
76
106
  backend,
77
107
  state: 'playing',
@@ -79,6 +109,7 @@ export class PlayerController {
79
109
  volume: this.getSettings().volume,
80
110
  muted: false,
81
111
  stationName: station.name,
112
+ airPlayDeviceName,
82
113
  streamUrl: url,
83
114
  startedAt: new Date().toISOString(),
84
115
  ready: true
@@ -89,56 +120,71 @@ export class PlayerController {
89
120
  }
90
121
  async togglePause() {
91
122
  if (!this.process || !this.backend || !this.state.ready) {
92
- return;
123
+ return { ok: false };
124
+ }
125
+ const unsupported = this.unsupportedFfplayControl();
126
+ if (unsupported) {
127
+ return unsupported;
93
128
  }
94
129
  if (this.backend === 'mpv') {
95
130
  await this.sendMpv({ command: ['cycle', 'pause'] }).catch(() => undefined);
96
131
  const synced = await this.syncMpvPlaybackState();
97
132
  if (synced) {
98
- return;
133
+ return { ok: true };
99
134
  }
100
135
  }
101
- else {
102
- this.process.stdin.write('p');
136
+ else if (this.backend === 'airplay') {
137
+ this.setState({ ...this.state, message: 'AirPlay pause is not supported. Use :stop to end playback.' });
138
+ return { ok: false, message: 'AirPlay pause is not supported. Use :stop to end playback.' };
103
139
  }
104
140
  this.setState({
105
141
  ...this.state,
106
142
  state: this.state.state === 'paused' ? 'playing' : 'paused'
107
143
  });
144
+ return { ok: true };
108
145
  }
109
146
  async setVolume(volume) {
110
147
  const clamped = clampVolume(volume);
148
+ const unsupported = this.unsupportedFfplayControl();
149
+ if (unsupported) {
150
+ return unsupported;
151
+ }
111
152
  if (this.backend === 'mpv') {
112
153
  await this.sendMpv({ command: ['set_property', 'volume', clamped] }).catch(() => undefined);
113
154
  }
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
- }
155
+ else if (this.backend === 'airplay') {
156
+ this.sendAirPlayCommand({ type: 'setVolume', volume: clamped });
121
157
  }
122
158
  this.setState({ ...this.state, volume: clamped });
159
+ return { ok: true };
123
160
  }
124
161
  async adjustVolume(delta) {
125
- await this.setVolume(this.state.volume + delta);
162
+ return this.setVolume(this.state.volume + delta);
126
163
  }
127
164
  async toggleMute() {
128
165
  const muted = !this.state.muted;
166
+ const unsupported = this.unsupportedFfplayControl();
167
+ if (unsupported) {
168
+ return unsupported;
169
+ }
129
170
  if (this.backend === 'mpv') {
130
171
  await this.sendMpv({ command: ['set_property', 'mute', muted] }).catch(() => undefined);
131
172
  }
132
- else if (this.backend === 'ffplay' && this.process) {
133
- this.process.stdin.write('m');
173
+ else if (this.backend === 'airplay') {
174
+ this.sendAirPlayCommand({ type: 'setMuted', muted });
134
175
  }
135
176
  this.setState({ ...this.state, muted });
177
+ return { ok: true };
136
178
  }
137
179
  async stop() {
138
180
  this.stopMpvPolling();
181
+ this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
139
182
  if (this.backend === 'mpv') {
140
183
  await this.sendMpv({ command: ['quit'] }).catch(() => undefined);
141
184
  }
185
+ else if (this.backend === 'airplay') {
186
+ this.sendAirPlayCommand({ type: 'stop' });
187
+ }
142
188
  if (this.process && !this.process.killed) {
143
189
  this.process.kill('SIGTERM');
144
190
  }
@@ -151,6 +197,25 @@ export class PlayerController {
151
197
  ready: false
152
198
  });
153
199
  }
200
+ submitAirPlayPasscode(code) {
201
+ if (this.backend !== 'airplay') {
202
+ return;
203
+ }
204
+ const trimmed = code.trim();
205
+ 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;
208
+ }
209
+ this.sendAirPlayCommand({ type: 'passcode', code: trimmed });
210
+ this.setState({ ...this.state, message: 'AirPlay code sent.' });
211
+ }
212
+ async refreshAirPlayDevices() {
213
+ this.availableAirPlayDevices = await discoverAirPlayDevices();
214
+ return [...this.availableAirPlayDevices];
215
+ }
216
+ detectedAirPlayDevices() {
217
+ return [...this.availableAirPlayDevices];
218
+ }
154
219
  selectBackend() {
155
220
  const preferred = this.getSettings().preferredBackend;
156
221
  const backends = this.availableBackends ?? this.refreshDetectedBackends();
@@ -160,14 +225,30 @@ export class PlayerController {
160
225
  if (preferred === 'ffplay') {
161
226
  return backends.includes('ffplay') ? 'ffplay' : null;
162
227
  }
228
+ if (preferred === 'airplay') {
229
+ return backends.includes('airplay') ? 'airplay' : null;
230
+ }
163
231
  if (backends.includes('mpv')) {
164
232
  return 'mpv';
165
233
  }
166
234
  if (backends.includes('ffplay')) {
167
235
  return 'ffplay';
168
236
  }
237
+ if (backends.includes('airplay')) {
238
+ return 'airplay';
239
+ }
169
240
  return null;
170
241
  }
242
+ playbackUnavailableMessage() {
243
+ const preferred = this.getSettings().preferredBackend;
244
+ 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}`;
246
+ }
247
+ if (preferred === 'mpv' || preferred === 'ffplay') {
248
+ return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
249
+ }
250
+ return `No playback backend found. ${playbackBackendInstallHint()}`;
251
+ }
171
252
  playWithMpv(url, initialTitle) {
172
253
  this.ipcPath = createMpvIpcPath();
173
254
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
@@ -190,6 +271,96 @@ export class PlayerController {
190
271
  });
191
272
  this.wireProcess();
192
273
  }
274
+ async resolveAirPlayDevice() {
275
+ const devices = await this.refreshAirPlayDevices();
276
+ const preferred = this.getSettings().preferredAirPlayDevice;
277
+ const device = devices.find(candidate => candidate.id === preferred) ?? devices[0];
278
+ if (!device) {
279
+ throw new Error('No AirPlay receiver found. Make sure the receiver is on the same network.');
280
+ }
281
+ return device;
282
+ }
283
+ playWithAirPlay(url, stationName, device) {
284
+ const workerPath = airPlayWorkerPath();
285
+ const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({
286
+ streamUrl: url,
287
+ stationName,
288
+ volume: this.getSettings().volume,
289
+ muted: false,
290
+ device
291
+ }));
292
+ this.process = spawn(process.execPath, workerArgs, {
293
+ stdio: ['pipe', 'pipe', 'pipe']
294
+ });
295
+ this.wireAirPlayProcess();
296
+ this.wireProcess();
297
+ const child = this.process;
298
+ return new Promise((resolve, reject) => {
299
+ const timeout = setTimeout(() => {
300
+ this.airPlayReadyResolver = null;
301
+ this.airPlayReadyRejecter = null;
302
+ const error = new Error(`Timed out while opening AirPlay stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
303
+ this.stopAirPlayProcess(child);
304
+ this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: error.message });
305
+ reject(error);
306
+ }, this.getSettings().tuneTimeoutSeconds * 1000);
307
+ this.airPlayReadyResolver = result => {
308
+ clearTimeout(timeout);
309
+ this.airPlayReadyResolver = null;
310
+ this.airPlayReadyRejecter = null;
311
+ resolve(result);
312
+ };
313
+ this.airPlayReadyRejecter = error => {
314
+ clearTimeout(timeout);
315
+ this.airPlayReadyResolver = null;
316
+ this.airPlayReadyRejecter = null;
317
+ reject(error);
318
+ };
319
+ });
320
+ }
321
+ wireAirPlayProcess() {
322
+ const child = this.process;
323
+ if (!child) {
324
+ return;
325
+ }
326
+ let buffer = '';
327
+ child.stdout.on('data', chunk => {
328
+ buffer += chunk.toString('utf8');
329
+ let newlineIndex = buffer.indexOf('\n');
330
+ while (newlineIndex !== -1) {
331
+ const line = buffer.slice(0, newlineIndex);
332
+ buffer = buffer.slice(newlineIndex + 1);
333
+ newlineIndex = buffer.indexOf('\n');
334
+ const event = parseWorkerMessage(line);
335
+ if (event) {
336
+ this.handleAirPlayEvent(event);
337
+ }
338
+ }
339
+ });
340
+ }
341
+ handleAirPlayEvent(event) {
342
+ if (event.type === 'ready' || event.type === 'playing') {
343
+ this.airPlayReadyResolver?.('ready');
344
+ if (this.backend === 'airplay') {
345
+ this.setState({ ...this.state, backend: 'airplay', state: 'playing', ready: true, message: this.state.stationName });
346
+ }
347
+ }
348
+ else if (event.type === 'password-required') {
349
+ this.airPlayReadyResolver?.('password-required');
350
+ this.setState({ ...this.state, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required. Use :airplay-code 1234.' });
351
+ }
352
+ else if (event.type === 'error') {
353
+ const error = new Error(event.message);
354
+ this.airPlayReadyRejecter?.(error);
355
+ this.setState({ ...this.state, backend: 'airplay', state: 'error', ready: false, message: event.message });
356
+ }
357
+ }
358
+ unsupportedFfplayControl() {
359
+ if (this.backend === 'ffplay' && this.process) {
360
+ return { ok: false, message: ffplayLimitedControlsMessage };
361
+ }
362
+ return null;
363
+ }
193
364
  wireProcess() {
194
365
  const child = this.process;
195
366
  if (!child) {
@@ -222,6 +393,34 @@ export class PlayerController {
222
393
  sendMpv(payload) {
223
394
  return this.queryMpv(payload).then(() => undefined);
224
395
  }
396
+ sendAirPlayCommand(command) {
397
+ if (this.backend === 'airplay' && this.process && !this.process.killed) {
398
+ this.process.stdin.write(serializeWorkerMessage(command));
399
+ }
400
+ }
401
+ stopAirPlayProcess(child) {
402
+ if (!child) {
403
+ return;
404
+ }
405
+ if (!child.killed) {
406
+ try {
407
+ child.stdin.write(serializeWorkerMessage({ type: 'stop' }));
408
+ }
409
+ catch {
410
+ // The worker may already be exiting.
411
+ }
412
+ child.kill('SIGTERM');
413
+ }
414
+ if (this.process === child) {
415
+ this.process = null;
416
+ }
417
+ }
418
+ rejectPendingAirPlayReady(error) {
419
+ const rejecter = this.airPlayReadyRejecter;
420
+ this.airPlayReadyResolver = null;
421
+ this.airPlayReadyRejecter = null;
422
+ rejecter?.(error);
423
+ }
225
424
  queryMpv(payload) {
226
425
  if (!this.ipcPath) {
227
426
  return Promise.resolve(null);
@@ -398,6 +597,17 @@ export function createMpvIpcPath(platform = process.platform, pid = process.pid,
398
597
  }
399
598
  return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
400
599
  }
600
+ function airPlayWorkerPath() {
601
+ const currentPath = fileURLToPath(import.meta.url);
602
+ const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js';
603
+ return join(dirname(currentPath), `airplay-worker${extension}`);
604
+ }
605
+ function airPlayWorkerArgs(workerPath, encodedStart) {
606
+ if (workerPath.endsWith('.ts')) {
607
+ return ['--import', 'tsx', workerPath, encodedStart];
608
+ }
609
+ return [workerPath, encodedStart];
610
+ }
401
611
  function isWindowsNamedPipePath(path) {
402
612
  return path.startsWith('\\\\.\\pipe\\');
403
613
  }
@@ -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