@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.
- package/CHANGELOG.md +43 -3
- package/README.md +23 -27
- package/dist/player/airplay-discovery.js +34 -1
- package/dist/player/airplay-sender-health.js +19 -8
- package/dist/player/airplay-sender-patch.js +138 -0
- package/dist/player/airplay-worker-protocol.js +22 -7
- package/dist/player/airplay-worker.js +79 -39
- package/dist/player/backend-install.js +1 -1
- package/dist/player/player-controller.js +253 -36
- package/dist/storage/store.js +14 -5
- package/dist/ui/App.js +198 -25
- package/dist/ui/AppContent.js +10 -1
- package/dist/ui/airplay-settings.js +56 -0
- package/dist/ui/app-state.js +11 -15
- package/dist/ui/audio-output.js +42 -0
- package/dist/ui/page-footer.js +10 -2
- package/dist/ui/screen-items.js +3 -3
- package/dist/ui/screens/AirPlayCodeScreen.js +18 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +24 -0
- package/dist/ui/screens/SettingsScreen.js +8 -8
- package/dist/ui/use-app-input.js +38 -7
- package/dist/ui/use-command-executor.js +11 -3
- package/package.json +7 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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.
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/storage/store.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|