@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.
- package/CHANGELOG.md +60 -3
- package/CONTRIBUTING.md +5 -2
- package/README.md +44 -15
- package/dist/player/airplay-discovery.js +174 -0
- package/dist/player/airplay-sender-health.js +98 -0
- package/dist/player/airplay-sender-patch.js +138 -0
- package/dist/player/airplay-worker-protocol.js +144 -0
- package/dist/player/airplay-worker.js +218 -0
- package/dist/player/backend-install.js +69 -4
- package/dist/player/player-controller.js +447 -20
- package/dist/storage/store.js +16 -6
- package/dist/ui/App.js +226 -20
- package/dist/ui/AppContent.js +13 -3
- package/dist/ui/airplay-settings.js +56 -0
- package/dist/ui/app-state.js +16 -1
- package/dist/ui/audio-output.js +42 -0
- package/dist/ui/page-footer.js +16 -2
- package/dist/ui/playback-footer.js +7 -0
- package/dist/ui/screen-items.js +3 -2
- package/dist/ui/screens/AirPlayCodeScreen.js +18 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +24 -0
- package/dist/ui/screens/HomeScreen.js +2 -1
- package/dist/ui/screens/SettingsScreen.js +14 -6
- package/dist/ui/use-app-input.js +46 -7
- package/dist/ui/use-command-executor.js +17 -1
- package/package.json +5 -1
|
@@ -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
|
-
|
|
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'
|
|
74
|
-
|
|
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.
|
|
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 === '
|
|
115
|
-
|
|
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
|
-
|
|
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 === '
|
|
133
|
-
this.
|
|
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
|
}
|
package/dist/storage/store.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|