@ciphore/radiocli 0.2.4 → 0.2.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 +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -2,14 +2,17 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
5
|
+
import { airPlayLocalDeviceLabel, detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
6
6
|
import { resolveCommand } from '../platform/executables.js';
|
|
7
|
+
import { airPlayPolicy, airPlaySetupRequiredMessage } from './airplay-policy.js';
|
|
7
8
|
import { discoverAirPlayDevices } from './airplay-discovery.js';
|
|
8
9
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
9
10
|
import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
|
|
10
11
|
import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
11
12
|
import { MpvIpcClient } from './mpv-ipc-client.js';
|
|
12
13
|
import { mpvIpcPath } from '../platform/ipc.js';
|
|
14
|
+
import { desktopMediaPolicy } from '../platform/desktop-media.js';
|
|
15
|
+
import { canRetryPlainHttp, ffmpegCertificateFailure, ffmpegInputArguments, ffmpegInputFailure } from './ffmpeg-input.js';
|
|
13
16
|
const minAirPlayTuneTimeoutSeconds = 30;
|
|
14
17
|
const maxPlayerDiagnosticCharacters = 4096;
|
|
15
18
|
export class PlaybackOutputError extends Error {
|
|
@@ -44,11 +47,13 @@ export class PlayerController {
|
|
|
44
47
|
airPlayPasscodes = new Map();
|
|
45
48
|
currentAirPlayDevice = null;
|
|
46
49
|
currentAirPlayDeviceId = null;
|
|
50
|
+
selectedAirPlayDeviceId = null;
|
|
47
51
|
pendingAirPlayPasscode = null;
|
|
48
52
|
currentMpvMediaTitle = null;
|
|
49
53
|
metadataPollInFlight = false;
|
|
50
54
|
playbackStatePollInFlight = false;
|
|
51
55
|
mpvLiveRetuneInFlight = false;
|
|
56
|
+
mpvRetuneFailure;
|
|
52
57
|
pendingMpvVolume = null;
|
|
53
58
|
mpvVolumeFlush = null;
|
|
54
59
|
confirmedMpvVolume = 70;
|
|
@@ -56,6 +61,15 @@ export class PlayerController {
|
|
|
56
61
|
playbackSessionId = 0;
|
|
57
62
|
stopPromise = null;
|
|
58
63
|
lastPlayerExit = null;
|
|
64
|
+
activePlayerDiagnostic = '';
|
|
65
|
+
compatibleDesktopMedia = true;
|
|
66
|
+
playRequestId = 0;
|
|
67
|
+
mpvObserved = false;
|
|
68
|
+
stationIsRadio = false;
|
|
69
|
+
mpvMediaIsLive = false;
|
|
70
|
+
mpvProgress = null;
|
|
71
|
+
mpvStallTimer;
|
|
72
|
+
fallbackAudioStarted = false;
|
|
59
73
|
constructor(getSettings, runtime = { platform: process.platform, arch: process.arch, env: process.env }) {
|
|
60
74
|
this.getSettings = getSettings;
|
|
61
75
|
this.runtime = runtime;
|
|
@@ -72,11 +86,24 @@ export class PlayerController {
|
|
|
72
86
|
getState() {
|
|
73
87
|
return { ...this.state };
|
|
74
88
|
}
|
|
89
|
+
desktopMediaCompatible() { return this.compatibleDesktopMedia; }
|
|
90
|
+
/** Retry diagnosed transport failures and interrupted live radio, never
|
|
91
|
+
* completed recordings, authentication or device/decoder initialization. */
|
|
92
|
+
recoverableFailure() {
|
|
93
|
+
const exit = this.lastPlayerExit;
|
|
94
|
+
if (!exit || exit.spawnError || exit.signal || exit.code === 0 || !/^https?:\/\//i.test(this.state.streamUrl ?? ''))
|
|
95
|
+
return false;
|
|
96
|
+
const detail = `${exit.diagnostic} ${this.state.message ?? ''}`;
|
|
97
|
+
if (ffmpegCertificateFailure(detail) || ffmpegInputFailure(detail))
|
|
98
|
+
return false;
|
|
99
|
+
return (exit.transportFailure === true || /timed? out|connection (?:reset|refused|closed)|network (?:is )?unreachable|temporary failure|unexpected (?:end|eof)|http (?:error )?50[234]/i.test(detail))
|
|
100
|
+
&& !/password|passcode|authentication|unauthori[sz]ed|forbidden|certificate|tls.*verif|audio output|audio device|decoder|unknown option/i.test(detail);
|
|
101
|
+
}
|
|
75
102
|
diagnostics() {
|
|
76
103
|
return {
|
|
77
104
|
backend: this.state.backend,
|
|
78
105
|
availableBackends: this.detectedBackends(),
|
|
79
|
-
preferredBackend: this.
|
|
106
|
+
preferredBackend: this.effectivePreferredBackend(),
|
|
80
107
|
active: Boolean(this.process),
|
|
81
108
|
streamUrl: this.state.streamUrl,
|
|
82
109
|
stationName: this.state.stationName,
|
|
@@ -87,43 +114,73 @@ export class PlayerController {
|
|
|
87
114
|
};
|
|
88
115
|
}
|
|
89
116
|
detectedBackends() {
|
|
90
|
-
return [...(this.availableBackends ?? [])];
|
|
117
|
+
return [...(this.availableBackends ?? [])].filter(backend => backend !== 'airplay' || this.airPlayPolicy().enabled);
|
|
91
118
|
}
|
|
92
119
|
refreshDetectedBackends() {
|
|
93
|
-
this.availableBackends = detectPlaybackBackends({ platform: this.runtime.platform });
|
|
120
|
+
this.availableBackends = detectPlaybackBackends({ settings: this.getSettings(), platform: this.runtime.platform, env: this.runtime.env });
|
|
94
121
|
return this.detectedBackends();
|
|
95
122
|
}
|
|
96
|
-
async play(station, url) {
|
|
123
|
+
async play(station, url, options = {}) {
|
|
124
|
+
const requestId = ++this.playRequestId;
|
|
125
|
+
const assertCurrent = () => {
|
|
126
|
+
options.signal?.throwIfAborted();
|
|
127
|
+
if (requestId !== this.playRequestId)
|
|
128
|
+
throw new DOMException('Playback request was superseded.', 'AbortError');
|
|
129
|
+
};
|
|
130
|
+
assertCurrent();
|
|
131
|
+
const abort = () => { if (requestId === this.playRequestId)
|
|
132
|
+
void this.stop().catch(() => undefined); };
|
|
133
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
134
|
+
try {
|
|
135
|
+
await this.playTarget(station, url, options, assertCurrent);
|
|
136
|
+
assertCurrent();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
options.signal?.removeEventListener('abort', abort);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async playTarget(station, url, options, assertCurrent) {
|
|
97
143
|
const target = safeMediaTarget(url);
|
|
98
144
|
if (!target) {
|
|
99
145
|
throw new Error(`Station ${station.name} returned an unsupported stream URL.`);
|
|
100
146
|
}
|
|
101
147
|
url = target;
|
|
148
|
+
if (this.stopPromise) {
|
|
149
|
+
await this.stopPromise;
|
|
150
|
+
assertCurrent();
|
|
151
|
+
}
|
|
102
152
|
const backend = this.selectBackend();
|
|
153
|
+
if (options.startPaused && backend !== 'mpv')
|
|
154
|
+
throw new PlaybackOutputError('This output cannot start a station paused.');
|
|
103
155
|
if (!backend) {
|
|
104
|
-
await this.
|
|
156
|
+
await this.stopPlayback();
|
|
105
157
|
throw new PlaybackOutputError(this.playbackUnavailableMessage());
|
|
106
158
|
}
|
|
107
159
|
if (backend === 'airplay') {
|
|
108
160
|
const activeDevice = this.activeAirPlayDeviceForRetune();
|
|
109
161
|
if (activeDevice) {
|
|
110
162
|
await this.retuneAirPlay(url, station.name, activeDevice);
|
|
163
|
+
assertCurrent();
|
|
111
164
|
return;
|
|
112
165
|
}
|
|
113
166
|
let device;
|
|
114
167
|
try {
|
|
115
168
|
device = await this.resolveAirPlayDevice();
|
|
169
|
+
assertCurrent();
|
|
116
170
|
}
|
|
117
171
|
catch (error) {
|
|
172
|
+
assertCurrent();
|
|
118
173
|
const message = error instanceof Error ? error.message : 'Could not resolve AirPlay receiver.';
|
|
119
174
|
this.setState({ ...this.state, backend, state: 'error', message, ready: false });
|
|
120
175
|
throw error;
|
|
121
176
|
}
|
|
122
177
|
if (this.canRetuneAirPlay(device)) {
|
|
123
178
|
await this.retuneAirPlay(url, station.name, device);
|
|
179
|
+
assertCurrent();
|
|
124
180
|
return;
|
|
125
181
|
}
|
|
126
|
-
await this.
|
|
182
|
+
await this.stopPlayback();
|
|
183
|
+
assertCurrent();
|
|
127
184
|
this.backend = backend;
|
|
128
185
|
this.currentAirPlayDevice = device;
|
|
129
186
|
this.currentAirPlayDeviceId = device.id;
|
|
@@ -139,6 +196,7 @@ export class PlayerController {
|
|
|
139
196
|
});
|
|
140
197
|
this.setState({ ...this.state, airPlayDeviceName: device.name });
|
|
141
198
|
const result = await this.playWithAirPlay(url, station.name, device);
|
|
199
|
+
assertCurrent();
|
|
142
200
|
if (result === 'password-required') {
|
|
143
201
|
if (this.pendingAirPlayPasscode) {
|
|
144
202
|
return;
|
|
@@ -167,8 +225,12 @@ export class PlayerController {
|
|
|
167
225
|
});
|
|
168
226
|
return;
|
|
169
227
|
}
|
|
170
|
-
await this.
|
|
228
|
+
await this.stopPlayback();
|
|
229
|
+
assertCurrent();
|
|
171
230
|
this.backend = backend;
|
|
231
|
+
this.stationIsRadio = station.provider !== 'playlist';
|
|
232
|
+
this.mpvMediaIsLive = false;
|
|
233
|
+
this.mpvProgress = null;
|
|
172
234
|
this.setState({
|
|
173
235
|
backend,
|
|
174
236
|
state: 'loading',
|
|
@@ -180,14 +242,17 @@ export class PlayerController {
|
|
|
180
242
|
ready: false
|
|
181
243
|
});
|
|
182
244
|
if (backend === 'mpv') {
|
|
183
|
-
this.playWithMpv(url, station.name);
|
|
245
|
+
this.playWithMpv(url, station.name, undefined, options.startPaused);
|
|
184
246
|
try {
|
|
185
247
|
await this.waitForReady(backend);
|
|
248
|
+
assertCurrent();
|
|
186
249
|
}
|
|
187
250
|
catch (error) {
|
|
251
|
+
assertCurrent();
|
|
188
252
|
if (!this.shouldRetryMpvWithAlsa(error))
|
|
189
253
|
throw error;
|
|
190
|
-
await this.
|
|
254
|
+
await this.stopPlayback();
|
|
255
|
+
assertCurrent();
|
|
191
256
|
this.backend = backend;
|
|
192
257
|
this.setState({
|
|
193
258
|
backend,
|
|
@@ -199,21 +264,45 @@ export class PlayerController {
|
|
|
199
264
|
streamUrl: url,
|
|
200
265
|
ready: false
|
|
201
266
|
});
|
|
202
|
-
this.playWithMpv(url, station.name, 'alsa,');
|
|
267
|
+
this.playWithMpv(url, station.name, 'alsa,', options.startPaused);
|
|
203
268
|
await this.waitForReady(backend);
|
|
269
|
+
assertCurrent();
|
|
204
270
|
}
|
|
205
271
|
}
|
|
206
272
|
else if (backend === 'ffplay') {
|
|
207
|
-
this.playWithFfplay(url);
|
|
208
|
-
|
|
273
|
+
const closed = this.playWithFfplay(url);
|
|
274
|
+
try {
|
|
275
|
+
await this.waitForReady(backend);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
assertCurrent();
|
|
279
|
+
if (this.process || this.lastPlayerExit?.spawnError || this.lastPlayerExit?.signal)
|
|
280
|
+
throw error;
|
|
281
|
+
// close follows stderr drain; exit can precede the diagnostic that
|
|
282
|
+
// distinguishes an unused TLS option from an actual network failure.
|
|
283
|
+
await Promise.race([closed, delay(250)]);
|
|
284
|
+
assertCurrent();
|
|
285
|
+
const inputFailure = ffmpegInputFailure(this.lastPlayerExit?.diagnostic ?? '');
|
|
286
|
+
if (inputFailure)
|
|
287
|
+
throw new PlaybackOutputError(inputFailure);
|
|
288
|
+
if (this.fallbackAudioStarted || !canRetryPlainHttp(url, this.lastPlayerExit?.diagnostic ?? ''))
|
|
289
|
+
throw error;
|
|
290
|
+
await this.stopPlayback();
|
|
291
|
+
assertCurrent();
|
|
292
|
+
this.setState({ ...this.state, state: 'loading', ready: false, message: `Opening ${station.name}` });
|
|
293
|
+
this.playWithFfplay(url, true);
|
|
294
|
+
await this.waitForReady(backend);
|
|
295
|
+
}
|
|
296
|
+
assertCurrent();
|
|
209
297
|
}
|
|
210
298
|
else if (backend === 'vlc') {
|
|
211
299
|
this.playWithVlc(url);
|
|
212
300
|
await this.waitForReady(backend);
|
|
301
|
+
assertCurrent();
|
|
213
302
|
}
|
|
214
303
|
this.setState({
|
|
215
304
|
backend,
|
|
216
|
-
state: 'playing',
|
|
305
|
+
state: options.startPaused ? 'paused' : 'playing',
|
|
217
306
|
message: station.name,
|
|
218
307
|
volume: this.getSettings().volume,
|
|
219
308
|
muted: false,
|
|
@@ -279,27 +368,53 @@ export class PlayerController {
|
|
|
279
368
|
const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null;
|
|
280
369
|
if (!url)
|
|
281
370
|
return { ok: false, message: 'The paused station no longer has a usable stream URL.' };
|
|
371
|
+
const client = this.mpvIpcClient;
|
|
372
|
+
const session = this.mpvSessionId;
|
|
373
|
+
if (!client)
|
|
374
|
+
return { ok: false, message: 'mpv is no longer connected.' };
|
|
282
375
|
this.mpvLiveRetuneInFlight = true;
|
|
376
|
+
this.mpvRetuneFailure = undefined;
|
|
283
377
|
try {
|
|
284
378
|
// Reloading the same URL discards mpv's delayed live buffer and opens a
|
|
285
379
|
// fresh stream connection. This keeps radio pause/play semantics at the
|
|
286
380
|
// live edge instead of behaving like time-shifted on-demand audio.
|
|
287
|
-
await
|
|
288
|
-
|
|
381
|
+
await client.query({ command: ['loadfile', url, 'replace'] });
|
|
382
|
+
if (session !== this.mpvSessionId)
|
|
383
|
+
return { ok: false, message: 'Playback changed during resume.' };
|
|
384
|
+
await client.query({ command: ['set_property', 'pause', false] });
|
|
385
|
+
if (session !== this.mpvSessionId)
|
|
386
|
+
return { ok: false, message: 'Playback changed during resume.' };
|
|
387
|
+
if (this.mpvRetuneFailure)
|
|
388
|
+
throw new Error(this.mpvRetuneFailure);
|
|
389
|
+
await this.waitForReady('mpv');
|
|
390
|
+
const idle = await client.query({ command: ['get_property', 'idle-active'] }).catch(() => null);
|
|
391
|
+
if (this.mpvRetuneFailure || idle === true)
|
|
392
|
+
throw new Error(this.mpvRetuneFailure ?? 'The station ended while reconnecting.');
|
|
289
393
|
}
|
|
290
394
|
catch {
|
|
395
|
+
if (session === this.mpvSessionId) {
|
|
396
|
+
const terminal = { ...this.state, state: 'error', ready: false, buffering: false, message: this.mpvRetuneFailure ?? 'mpv could not reconnect the paused station at the live edge.' };
|
|
397
|
+
void this.retireMpvStream(terminal).catch(() => undefined);
|
|
398
|
+
}
|
|
291
399
|
return { ok: false, message: 'mpv could not reconnect the paused station at the live edge.' };
|
|
292
400
|
}
|
|
293
401
|
finally {
|
|
294
|
-
this.
|
|
402
|
+
if (session === this.mpvSessionId)
|
|
403
|
+
this.mpvLiveRetuneInFlight = false;
|
|
295
404
|
}
|
|
405
|
+
if (session !== this.mpvSessionId)
|
|
406
|
+
return { ok: false, message: 'Playback changed during resume.' };
|
|
296
407
|
this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI';
|
|
297
408
|
this.emitMetadata({ updatedAt: new Date().toISOString() });
|
|
298
409
|
this.setState({ ...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString() });
|
|
299
410
|
return { ok: true, message: 'Reconnected at the live broadcast.' };
|
|
300
411
|
}
|
|
301
|
-
setVolume(volume) {
|
|
412
|
+
setVolume(volume, options = {}) {
|
|
302
413
|
const clamped = clampVolume(volume);
|
|
414
|
+
if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) {
|
|
415
|
+
this.setState({ ...this.state, volume: clamped });
|
|
416
|
+
return Promise.resolve({ ok: true });
|
|
417
|
+
}
|
|
303
418
|
const unsupported = this.unsupportedFfplayControl();
|
|
304
419
|
if (unsupported) {
|
|
305
420
|
return Promise.resolve(unsupported);
|
|
@@ -340,7 +455,11 @@ export class PlayerController {
|
|
|
340
455
|
this.setState({ ...this.state, muted });
|
|
341
456
|
return { ok: true };
|
|
342
457
|
}
|
|
343
|
-
async setMuted(muted) {
|
|
458
|
+
async setMuted(muted, options = {}) {
|
|
459
|
+
if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) {
|
|
460
|
+
this.setState({ ...this.state, muted });
|
|
461
|
+
return { ok: true };
|
|
462
|
+
}
|
|
344
463
|
const unsupported = this.unsupportedFfplayControl();
|
|
345
464
|
if (unsupported)
|
|
346
465
|
return this.state.muted === muted ? { ok: true } : unsupported;
|
|
@@ -367,6 +486,10 @@ export class PlayerController {
|
|
|
367
486
|
return { ok: true };
|
|
368
487
|
}
|
|
369
488
|
stop() {
|
|
489
|
+
this.playRequestId++;
|
|
490
|
+
return this.stopPlayback();
|
|
491
|
+
}
|
|
492
|
+
stopPlayback() {
|
|
370
493
|
if (this.stopPromise)
|
|
371
494
|
return this.stopPromise;
|
|
372
495
|
const operation = this.performStop();
|
|
@@ -377,6 +500,7 @@ export class PlayerController {
|
|
|
377
500
|
}
|
|
378
501
|
async performStop() {
|
|
379
502
|
const child = this.process;
|
|
503
|
+
this.mpvMediaIsLive = false;
|
|
380
504
|
const startupInProgress = this.state.state === 'loading' && !this.state.ready;
|
|
381
505
|
this.stopMpvPolling();
|
|
382
506
|
this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
|
|
@@ -409,7 +533,11 @@ export class PlayerController {
|
|
|
409
533
|
ready: false
|
|
410
534
|
});
|
|
411
535
|
}
|
|
536
|
+
airPlayPolicy() { return airPlayPolicy(this.getSettings(), this.runtime); }
|
|
412
537
|
submitAirPlayPasscode(code) {
|
|
538
|
+
const policy = this.airPlayPolicy();
|
|
539
|
+
if (!policy.enabled)
|
|
540
|
+
return { ok: false, message: policy.message };
|
|
413
541
|
if (this.backend !== 'airplay') {
|
|
414
542
|
return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
|
|
415
543
|
}
|
|
@@ -428,15 +556,34 @@ export class PlayerController {
|
|
|
428
556
|
return { ok: true, message };
|
|
429
557
|
}
|
|
430
558
|
async refreshAirPlayDevices() {
|
|
431
|
-
|
|
559
|
+
const policy = this.airPlayPolicy();
|
|
560
|
+
if (!policy.enabled)
|
|
561
|
+
throw new PlaybackOutputError(policy.message);
|
|
562
|
+
this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform, env: this.runtime.env });
|
|
432
563
|
return [...this.availableAirPlayDevices];
|
|
433
564
|
}
|
|
434
565
|
detectedAirPlayDevices() {
|
|
435
566
|
return [...this.availableAirPlayDevices];
|
|
436
567
|
}
|
|
568
|
+
selectAirPlayDevice(deviceId) {
|
|
569
|
+
if (deviceId !== null && !this.airPlayPolicy().enabled)
|
|
570
|
+
return false;
|
|
571
|
+
if (deviceId !== null && !this.availableAirPlayDevices.some(device => device.id === deviceId && !device.local))
|
|
572
|
+
return false;
|
|
573
|
+
this.selectedAirPlayDeviceId = deviceId;
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
airPlaySelectionRequired() {
|
|
577
|
+
return this.runtime.platform === 'linux' && (!this.selectedAirPlayDeviceId || this.selectedAirPlayDeviceId !== this.getSettings().preferredAirPlayDevice);
|
|
578
|
+
}
|
|
579
|
+
effectivePreferredBackend() {
|
|
580
|
+
// A persisted receiver is a convenience for the picker, never consent to
|
|
581
|
+
// open a Linux network output again in a new process.
|
|
582
|
+
return this.getSettings().preferredBackend === 'airplay' && (this.runtime.platform === 'linux' && !this.airPlayPolicy().enabled || this.airPlaySelectionRequired()) ? 'auto' : this.getSettings().preferredBackend;
|
|
583
|
+
}
|
|
437
584
|
selectBackend() {
|
|
438
|
-
const preferred = this.
|
|
439
|
-
const backends = this.availableBackends
|
|
585
|
+
const preferred = this.effectivePreferredBackend();
|
|
586
|
+
const backends = this.availableBackends ? this.detectedBackends() : this.refreshDetectedBackends();
|
|
440
587
|
if (preferred === 'mpv') {
|
|
441
588
|
return backends.includes('mpv') ? 'mpv' : null;
|
|
442
589
|
}
|
|
@@ -463,17 +610,17 @@ export class PlayerController {
|
|
|
463
610
|
playbackUnavailableMessage() {
|
|
464
611
|
const preferred = this.getSettings().preferredBackend;
|
|
465
612
|
if (preferred === 'airplay') {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return
|
|
613
|
+
const policy = this.airPlayPolicy();
|
|
614
|
+
if (!policy.enabled)
|
|
615
|
+
return policy.message;
|
|
616
|
+
return `${this.runtime.platform === 'linux' ? airPlaySetupRequiredMessage : 'AirPlay is not ready on this install. Run radiocli doctor.'} ${airPlaySenderHealth().message}`;
|
|
470
617
|
}
|
|
471
618
|
if (preferred === 'mpv' || preferred === 'ffplay') {
|
|
472
619
|
return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
|
|
473
620
|
}
|
|
474
621
|
return `No playback backend found. ${playbackBackendInstallHint()}`;
|
|
475
622
|
}
|
|
476
|
-
playWithMpv(url, initialTitle, recoveredAudioOutput) {
|
|
623
|
+
playWithMpv(url, initialTitle, recoveredAudioOutput, startPaused = false) {
|
|
477
624
|
this.playbackSessionId += 1;
|
|
478
625
|
this.ipcPath = mpvIpcPath();
|
|
479
626
|
this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
|
|
@@ -481,10 +628,20 @@ export class PlayerController {
|
|
|
481
628
|
this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
|
|
482
629
|
this.pendingMpvVolume = null;
|
|
483
630
|
this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
|
|
631
|
+
const desktopMedia = desktopMediaPolicy(this.getSettings(), this.runtime);
|
|
632
|
+
this.compatibleDesktopMedia = desktopMedia.enabled;
|
|
633
|
+
// Release the application service before an incompatible mpv process can
|
|
634
|
+
// autoload its own service, including config changes made between tunes.
|
|
635
|
+
this.setState({ ...this.state });
|
|
484
636
|
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
485
637
|
'--no-video',
|
|
486
638
|
'--msg-level=all=warn',
|
|
487
639
|
'--force-window=no',
|
|
640
|
+
// Several supported mpv/FFmpeg builds default to accepting any certificate.
|
|
641
|
+
// Apply to every input, including HTTP redirects and nested playlists.
|
|
642
|
+
'--tls-verify=yes',
|
|
643
|
+
...(startPaused ? ['--pause=yes'] : []),
|
|
644
|
+
...desktopMedia.mpvArgs,
|
|
488
645
|
...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
|
|
489
646
|
? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
|
|
490
647
|
: []),
|
|
@@ -497,12 +654,13 @@ export class PlayerController {
|
|
|
497
654
|
});
|
|
498
655
|
this.wireProcess();
|
|
499
656
|
}
|
|
500
|
-
playWithFfplay(url) {
|
|
657
|
+
playWithFfplay(url, plainHttp = false) {
|
|
501
658
|
this.playbackSessionId += 1;
|
|
502
|
-
this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
659
|
+
this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-stats', ...ffmpegInputArguments(url, { plainHttp }), '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
503
660
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
504
661
|
});
|
|
505
662
|
this.wireProcess();
|
|
663
|
+
return new Promise(resolve => this.process.once('close', resolve));
|
|
506
664
|
}
|
|
507
665
|
playWithVlc(url) {
|
|
508
666
|
this.playbackSessionId += 1;
|
|
@@ -510,7 +668,8 @@ export class PlayerController {
|
|
|
510
668
|
// binary (resolved from app bundles when off PATH) with a dummy interface.
|
|
511
669
|
const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
|
|
512
670
|
const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2);
|
|
513
|
-
this.process = spawn(binary, ['--intf', 'dummy', '--no-video', '--quiet', '--
|
|
671
|
+
this.process = spawn(binary, ['--intf', 'dummy', '--no-video', '--no-quiet', '--verbose=2', '--no-color', '--play-and-exit',
|
|
672
|
+
...(this.runtime.env.RADIOCLI_VLC_AUDIO_OUTPUT ? [`--aout=${this.runtime.env.RADIOCLI_VLC_AUDIO_OUTPUT}`] : []), `--gain=${gain}`, url], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
514
673
|
this.wireProcess();
|
|
515
674
|
}
|
|
516
675
|
canRetuneAirPlay(device) {
|
|
@@ -593,7 +752,7 @@ export class PlayerController {
|
|
|
593
752
|
throw new PlaybackOutputError('Selected AirPlay receiver was not found. Refresh AirPlay receivers in Settings.');
|
|
594
753
|
}
|
|
595
754
|
if (device.local) {
|
|
596
|
-
throw new PlaybackOutputError(`${device.name} is this
|
|
755
|
+
throw new PlaybackOutputError(`${device.name} is ${airPlayLocalDeviceLabel(this.runtime.platform)}. Use Audio output: This device instead of AirPlay.`);
|
|
597
756
|
}
|
|
598
757
|
return device;
|
|
599
758
|
}
|
|
@@ -738,17 +897,48 @@ export class PlayerController {
|
|
|
738
897
|
return;
|
|
739
898
|
}
|
|
740
899
|
this.lastPlayerExit = null;
|
|
900
|
+
this.activePlayerDiagnostic = '';
|
|
901
|
+
this.fallbackAudioStarted = false;
|
|
902
|
+
let progressBuffer = '';
|
|
903
|
+
let vlcAudioOutput = false;
|
|
904
|
+
let vlcDecoderStarted = false;
|
|
741
905
|
let diagnostic = '';
|
|
906
|
+
let exitRecord;
|
|
742
907
|
child.stdout.on('data', chunk => {
|
|
743
908
|
diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
|
|
909
|
+
if (this.process === child)
|
|
910
|
+
this.activePlayerDiagnostic = diagnostic;
|
|
744
911
|
});
|
|
745
912
|
child.stderr.on('data', chunk => {
|
|
746
913
|
diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
|
|
914
|
+
if (this.process === child)
|
|
915
|
+
this.activePlayerDiagnostic = diagnostic;
|
|
916
|
+
if (backend === 'ffplay' && this.process === child && !this.fallbackAudioStarted) {
|
|
917
|
+
progressBuffer = appendPlayerDiagnostic(progressBuffer, String(chunk));
|
|
918
|
+
const lines = progressBuffer.split(/[\r\n]/);
|
|
919
|
+
progressBuffer = lines.pop() ?? '';
|
|
920
|
+
// ffplay's audio-master clock is nan until audio reaches its output.
|
|
921
|
+
// Explicit -stats keeps this signal even with error-only diagnostics.
|
|
922
|
+
this.fallbackAudioStarted = lines.some(line => /^\s*-?\d+(?:\.\d+)?\s+M-A:\s*-?\d+(?:\.\d+)?\s+/.test(line));
|
|
923
|
+
}
|
|
924
|
+
else if (backend === 'vlc' && this.process === child && !this.fallbackAudioStarted) {
|
|
925
|
+
// Keep the portable dummy interface. RC stdin opens a separate console
|
|
926
|
+
// on Windows, and a loopback RC port would expose unauthenticated control.
|
|
927
|
+
// Both output initialization and released decoder buffering are needed;
|
|
928
|
+
// input headers or a surviving process alone never establish readiness.
|
|
929
|
+
vlcAudioOutput ||= /main audio output debug: output '[^'\r\n]+' \d+ Hz/.test(diagnostic);
|
|
930
|
+
vlcDecoderStarted ||= /main input debug: Decoder wait done in \d+ ms/.test(diagnostic);
|
|
931
|
+
this.fallbackAudioStarted = vlcAudioOutput && vlcDecoderStarted;
|
|
932
|
+
}
|
|
747
933
|
});
|
|
748
934
|
// Local players can write diagnostics indefinitely. Drain both pipes so a
|
|
749
935
|
// full OS pipe buffer can never stall playback.
|
|
750
936
|
child.stdout.resume?.();
|
|
751
937
|
child.stderr.resume?.();
|
|
938
|
+
child.once('close', () => {
|
|
939
|
+
if (exitRecord && this.lastPlayerExit === exitRecord)
|
|
940
|
+
exitRecord.diagnostic = diagnostic;
|
|
941
|
+
});
|
|
752
942
|
child.on('error', error => {
|
|
753
943
|
diagnostic = appendPlayerDiagnostic(diagnostic, error.message);
|
|
754
944
|
if (this.process !== child)
|
|
@@ -775,7 +965,11 @@ export class PlayerController {
|
|
|
775
965
|
});
|
|
776
966
|
child.on('exit', (code, signal) => {
|
|
777
967
|
if (this.process === child) {
|
|
778
|
-
|
|
968
|
+
// Worker failures carry receiver/pairing guidance before cleanup exits.
|
|
969
|
+
// Keep that reason while still clearing the exited process and session.
|
|
970
|
+
const workerError = backend === 'airplay' && this.state.state === 'error' ? this.state.message || undefined : undefined;
|
|
971
|
+
const liveEof = backend === 'mpv' && this.mpvMediaIsLive && this.state.ready && code === 0 && !signal;
|
|
972
|
+
this.lastPlayerExit = exitRecord = { backend, code: liveEof ? 1 : code, signal, diagnostic, transportFailure: liveEof };
|
|
779
973
|
this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
|
|
780
974
|
if (this.backend === 'airplay') {
|
|
781
975
|
this.currentAirPlayDevice = null;
|
|
@@ -789,8 +983,9 @@ export class PlayerController {
|
|
|
789
983
|
this.setState({
|
|
790
984
|
...this.state,
|
|
791
985
|
backend: this.backend ?? 'none',
|
|
792
|
-
state: code
|
|
793
|
-
message: code === 0 || code === null ? undefined : `player exited with code ${code}
|
|
986
|
+
state: workerError || liveEof || (code !== 0 && code !== null) ? 'error' : 'stopped',
|
|
987
|
+
message: workerError ?? (liveEof ? 'Live stream connection ended unexpectedly.' : code === 0 || code === null ? undefined : `player exited with code ${code}`),
|
|
988
|
+
buffering: false,
|
|
794
989
|
ready: false
|
|
795
990
|
});
|
|
796
991
|
}
|
|
@@ -903,20 +1098,16 @@ export class PlayerController {
|
|
|
903
1098
|
const wasSuperseded = () => this.playbackSessionId !== playbackSessionId;
|
|
904
1099
|
const isCurrent = () => !wasSuperseded() && this.process === child;
|
|
905
1100
|
while (Date.now() - started < timeoutMs) {
|
|
1101
|
+
if (backend === 'mpv' && this.mpvLiveRetuneInFlight && this.mpvRetuneFailure)
|
|
1102
|
+
throw new Error(this.mpvRetuneFailure);
|
|
906
1103
|
if (wasSuperseded()) {
|
|
907
1104
|
throw new Error(`${backend} playback request was superseded.`);
|
|
908
1105
|
}
|
|
909
1106
|
if (!child || this.process !== child) {
|
|
910
1107
|
throw this.playerExitedBeforeReady(backend);
|
|
911
1108
|
}
|
|
912
|
-
if (backend === 'ffplay' || backend === 'vlc')
|
|
913
|
-
await waitForStartupWindow(() => this.process === child ? child : null, Math.min(500, timeoutMs), () => this.playerExitedBeforeReady(backend));
|
|
914
|
-
if (wasSuperseded())
|
|
915
|
-
throw new Error(`${backend} playback request was superseded.`);
|
|
916
|
-
if (!isCurrent())
|
|
917
|
-
throw this.playerExitedBeforeReady(backend);
|
|
1109
|
+
if ((backend === 'ffplay' || backend === 'vlc') && this.fallbackAudioStarted)
|
|
918
1110
|
return;
|
|
919
|
-
}
|
|
920
1111
|
if (mpvClient) {
|
|
921
1112
|
try {
|
|
922
1113
|
await mpvClient.query({ command: ['get_property', 'path'] });
|
|
@@ -935,8 +1126,16 @@ export class PlayerController {
|
|
|
935
1126
|
}
|
|
936
1127
|
if (this.process !== child)
|
|
937
1128
|
throw this.playerExitedBeforeReady(backend);
|
|
938
|
-
|
|
939
|
-
|
|
1129
|
+
const requestId = this.playRequestId;
|
|
1130
|
+
const diagnostic = this.activePlayerDiagnostic;
|
|
1131
|
+
await this.stopPlayback();
|
|
1132
|
+
if (requestId !== this.playRequestId || wasSuperseded())
|
|
1133
|
+
throw new DOMException('Playback request was superseded.', 'AbortError');
|
|
1134
|
+
const detail = concisePlayerDiagnostic(diagnostic);
|
|
1135
|
+
const message = `Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.${detail ? ` ${detail}` : ''}`;
|
|
1136
|
+
this.lastPlayerExit = { backend, code: 1, signal: null, diagnostic };
|
|
1137
|
+
this.setState({ ...this.state, state: 'error', ready: false, buffering: false, message });
|
|
1138
|
+
throw new Error(message);
|
|
940
1139
|
}
|
|
941
1140
|
shouldRetryMpvWithAlsa(error) {
|
|
942
1141
|
return this.runtime.platform === 'linux' &&
|
|
@@ -951,6 +1150,9 @@ export class PlayerController {
|
|
|
951
1150
|
? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}`
|
|
952
1151
|
: 'before its exit status was available';
|
|
953
1152
|
const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? '');
|
|
1153
|
+
const inputFailure = backend === 'ffplay' ? ffmpegInputFailure(exit?.diagnostic ?? '') : undefined;
|
|
1154
|
+
if (inputFailure)
|
|
1155
|
+
return new PlaybackOutputError(inputFailure);
|
|
954
1156
|
const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`;
|
|
955
1157
|
return isBackendInitializationFailure(backend, exit, diagnostic)
|
|
956
1158
|
? new PlaybackOutputError(message)
|
|
@@ -961,10 +1163,65 @@ export class PlayerController {
|
|
|
961
1163
|
client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
|
|
962
1164
|
client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
|
|
963
1165
|
]);
|
|
964
|
-
|
|
1166
|
+
if (!Number.isFinite(timePos) && !Number.isFinite(audioPts))
|
|
1167
|
+
return false;
|
|
1168
|
+
const [duration, seekable] = await Promise.all([
|
|
1169
|
+
client.query({ command: ['get_property', 'duration'] }).catch(() => null),
|
|
1170
|
+
client.query({ command: ['get_property', 'seekable'] }).catch(() => null)
|
|
1171
|
+
]);
|
|
1172
|
+
if (client === this.mpvIpcClient) {
|
|
1173
|
+
// Directory stations are live even when HLS exposes a finite window.
|
|
1174
|
+
// For imported URLs, a known duration denotes a recording; nonseekable
|
|
1175
|
+
// inputs without a duration can still be live radio.
|
|
1176
|
+
this.mpvMediaIsLive = /^https?:\/\//i.test(this.state.streamUrl ?? '') &&
|
|
1177
|
+
(this.stationIsRadio || seekable === false && !(typeof duration === 'number' && Number.isFinite(duration) && duration > 0));
|
|
1178
|
+
}
|
|
1179
|
+
return true;
|
|
965
1180
|
}
|
|
966
1181
|
startMpvMetadataPolling() {
|
|
967
1182
|
this.stopMpvPolling();
|
|
1183
|
+
const client = this.mpvIpcClient;
|
|
1184
|
+
const session = this.mpvSessionId;
|
|
1185
|
+
this.mpvObserved = false;
|
|
1186
|
+
client?.onEvent(event => {
|
|
1187
|
+
if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
|
|
1188
|
+
return;
|
|
1189
|
+
if (event.event === 'end-file' && this.state.ready && ['eof', 'error'].includes(event.reason ?? '')) {
|
|
1190
|
+
if (this.mpvLiveRetuneInFlight) {
|
|
1191
|
+
this.mpvRetuneFailure = event.error ?? 'The station ended while reconnecting.';
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const liveEof = event.reason === 'eof' && this.mpvMediaIsLive;
|
|
1195
|
+
const terminal = { ...this.state, state: event.reason === 'error' || liveEof ? 'error' : 'stopped', ready: false, buffering: false,
|
|
1196
|
+
message: event.reason === 'error' ? event.error ?? 'mpv could not continue the stream.' : liveEof ? 'Live stream connection ended unexpectedly.' : 'Stream ended.' };
|
|
1197
|
+
// User mpv configuration may keep an idle process alive after EOF.
|
|
1198
|
+
// Retire it through the same bounded lifecycle as an explicit Stop.
|
|
1199
|
+
void this.retireMpvStream(terminal, liveEof).catch(() => undefined);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (event.event !== 'property-change')
|
|
1203
|
+
return;
|
|
1204
|
+
if (event.name === 'pause' && typeof event.data === 'boolean' && this.state.ready) {
|
|
1205
|
+
void this.applyMpvPause(event.data).catch(() => undefined);
|
|
1206
|
+
}
|
|
1207
|
+
else if (event.name === 'metadata' && event.data && typeof event.data === 'object') {
|
|
1208
|
+
void this.applyMpvMetadata(event.data, client, session);
|
|
1209
|
+
}
|
|
1210
|
+
else if (event.name === 'paused-for-cache' && typeof event.data === 'boolean' && this.state.buffering !== event.data) {
|
|
1211
|
+
this.setState({ ...this.state, buffering: event.data });
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
if (client)
|
|
1215
|
+
void Promise.all(['pause', 'metadata', 'paused-for-cache'].map((property, id) => client.observe(id + 1, property)))
|
|
1216
|
+
.then(() => {
|
|
1217
|
+
if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
|
|
1218
|
+
return;
|
|
1219
|
+
this.mpvObserved = true;
|
|
1220
|
+
if (this.playbackStateTimer)
|
|
1221
|
+
clearInterval(this.playbackStateTimer);
|
|
1222
|
+
this.playbackStateTimer = setInterval(() => { if (!this.playbackStatePollInFlight)
|
|
1223
|
+
void this.syncMpvPlaybackState(); }, 5000);
|
|
1224
|
+
}).catch(() => undefined);
|
|
968
1225
|
this.metadataTimer = setInterval(() => {
|
|
969
1226
|
if (!this.metadataPollInFlight)
|
|
970
1227
|
void this.pollMpvMetadata();
|
|
@@ -975,8 +1232,11 @@ export class PlayerController {
|
|
|
975
1232
|
}, 500);
|
|
976
1233
|
void this.pollMpvMetadata();
|
|
977
1234
|
void this.syncMpvPlaybackState();
|
|
1235
|
+
this.updateMpvStallTimer();
|
|
978
1236
|
}
|
|
979
1237
|
stopMpvPolling() {
|
|
1238
|
+
clearTimeout(this.mpvStallTimer);
|
|
1239
|
+
this.mpvStallTimer = undefined;
|
|
980
1240
|
if (this.metadataTimer) {
|
|
981
1241
|
clearInterval(this.metadataTimer);
|
|
982
1242
|
this.metadataTimer = null;
|
|
@@ -986,27 +1246,59 @@ export class PlayerController {
|
|
|
986
1246
|
this.playbackStateTimer = null;
|
|
987
1247
|
}
|
|
988
1248
|
}
|
|
1249
|
+
async retireMpvStream(terminal, transportFailure = false) {
|
|
1250
|
+
const request = this.playRequestId;
|
|
1251
|
+
const failure = terminal.state === 'error' ? { backend: 'mpv', code: 1, signal: null, diagnostic: terminal.message ?? '', transportFailure } : null;
|
|
1252
|
+
this.lastPlayerExit = failure;
|
|
1253
|
+
this.stopMpvPolling();
|
|
1254
|
+
this.setState(terminal);
|
|
1255
|
+
await this.stopPlayback();
|
|
1256
|
+
if (request === this.playRequestId) {
|
|
1257
|
+
this.lastPlayerExit = failure;
|
|
1258
|
+
this.setState(terminal);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
989
1261
|
async pollMpvMetadata() {
|
|
990
1262
|
if (this.backend !== 'mpv' || !this.process) {
|
|
991
1263
|
return;
|
|
992
1264
|
}
|
|
1265
|
+
const client = this.mpvIpcClient;
|
|
1266
|
+
const session = this.mpvSessionId;
|
|
1267
|
+
if (!client)
|
|
1268
|
+
return;
|
|
993
1269
|
this.metadataPollInFlight = true;
|
|
994
1270
|
try {
|
|
995
|
-
const metadata = await
|
|
996
|
-
|
|
997
|
-
if (
|
|
1271
|
+
const metadata = this.mpvObserved ? null : await client.query({ command: ['get_property', 'metadata'] }).catch(() => null);
|
|
1272
|
+
let elapsed = await client.query({ command: ['get_property', 'time-pos'] }).catch(() => null);
|
|
1273
|
+
if (!Number.isFinite(elapsed))
|
|
1274
|
+
elapsed = await client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null);
|
|
1275
|
+
if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
|
|
1276
|
+
return;
|
|
1277
|
+
if (typeof elapsed === 'number' && Number.isFinite(elapsed)) {
|
|
1278
|
+
if (elapsed !== this.mpvProgress) {
|
|
1279
|
+
this.mpvProgress = elapsed;
|
|
1280
|
+
clearTimeout(this.mpvStallTimer);
|
|
1281
|
+
this.mpvStallTimer = undefined;
|
|
1282
|
+
}
|
|
998
1283
|
this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
|
|
999
1284
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
await this.setMpvMediaTitle(title);
|
|
1003
|
-
this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
|
|
1004
|
-
}
|
|
1285
|
+
if (metadata)
|
|
1286
|
+
await this.applyMpvMetadata(metadata, client, session);
|
|
1005
1287
|
}
|
|
1006
1288
|
finally {
|
|
1007
|
-
this.
|
|
1289
|
+
if (session === this.mpvSessionId)
|
|
1290
|
+
this.metadataPollInFlight = false;
|
|
1008
1291
|
}
|
|
1009
1292
|
}
|
|
1293
|
+
async applyMpvMetadata(metadata, client, session) {
|
|
1294
|
+
const title = extractMpvTitle(metadata);
|
|
1295
|
+
if (!title)
|
|
1296
|
+
return;
|
|
1297
|
+
await this.setMpvMediaTitle(title);
|
|
1298
|
+
if (client !== this.mpvIpcClient || session !== this.mpvSessionId)
|
|
1299
|
+
return;
|
|
1300
|
+
this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
|
|
1301
|
+
}
|
|
1010
1302
|
async setMpvMediaTitle(title) {
|
|
1011
1303
|
const cleaned = cleanMediaTitle(title);
|
|
1012
1304
|
if (!cleaned || cleaned === this.currentMpvMediaTitle) {
|
|
@@ -1021,25 +1313,35 @@ export class PlayerController {
|
|
|
1021
1313
|
}
|
|
1022
1314
|
if (this.playbackStatePollInFlight)
|
|
1023
1315
|
return false;
|
|
1316
|
+
const client = this.mpvIpcClient;
|
|
1317
|
+
const session = this.mpvSessionId;
|
|
1318
|
+
if (!client)
|
|
1319
|
+
return false;
|
|
1024
1320
|
this.playbackStatePollInFlight = true;
|
|
1025
1321
|
try {
|
|
1026
|
-
const paused = await
|
|
1027
|
-
if (typeof paused !== 'boolean') {
|
|
1322
|
+
const paused = await client.query({ command: ['get_property', 'pause'] }).catch(() => null);
|
|
1323
|
+
if (typeof paused !== 'boolean' || session !== this.mpvSessionId || !this.state.ready) {
|
|
1028
1324
|
return false;
|
|
1029
1325
|
}
|
|
1030
|
-
|
|
1031
|
-
return (await this.resumeMpvAtLiveEdge()).ok;
|
|
1032
|
-
}
|
|
1033
|
-
const state = paused ? 'paused' : 'playing';
|
|
1034
|
-
if (this.state.state !== state) {
|
|
1035
|
-
this.setState({ ...this.state, state });
|
|
1036
|
-
}
|
|
1037
|
-
return true;
|
|
1326
|
+
return this.applyMpvPause(paused);
|
|
1038
1327
|
}
|
|
1039
1328
|
finally {
|
|
1040
|
-
this.
|
|
1329
|
+
if (session === this.mpvSessionId)
|
|
1330
|
+
this.playbackStatePollInFlight = false;
|
|
1041
1331
|
}
|
|
1042
1332
|
}
|
|
1333
|
+
async applyMpvPause(paused) {
|
|
1334
|
+
// Unpausing the decoder is only the first step of a live-edge reload.
|
|
1335
|
+
// Publish audible playback after the new stream passes readiness checks.
|
|
1336
|
+
if (this.mpvLiveRetuneInFlight)
|
|
1337
|
+
return true;
|
|
1338
|
+
if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight)
|
|
1339
|
+
return (await this.resumeMpvAtLiveEdge()).ok;
|
|
1340
|
+
const state = paused ? 'paused' : 'playing';
|
|
1341
|
+
if (this.state.state !== state)
|
|
1342
|
+
this.setState({ ...this.state, state });
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1043
1345
|
emitMetadata(metadata) {
|
|
1044
1346
|
for (const listener of this.metadataListeners) {
|
|
1045
1347
|
try {
|
|
@@ -1056,6 +1358,11 @@ export class PlayerController {
|
|
|
1056
1358
|
this.mpvIpcClient = null;
|
|
1057
1359
|
this.pendingMpvVolume = null;
|
|
1058
1360
|
this.mpvVolumeFlush = null;
|
|
1361
|
+
this.metadataPollInFlight = false;
|
|
1362
|
+
this.playbackStatePollInFlight = false;
|
|
1363
|
+
this.mpvLiveRetuneInFlight = false;
|
|
1364
|
+
this.mpvRetuneFailure = undefined;
|
|
1365
|
+
this.mpvObserved = false;
|
|
1059
1366
|
if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
|
|
1060
1367
|
try {
|
|
1061
1368
|
unlinkSync(this.ipcPath);
|
|
@@ -1069,10 +1376,29 @@ export class PlayerController {
|
|
|
1069
1376
|
}
|
|
1070
1377
|
setState(state) {
|
|
1071
1378
|
this.state = state;
|
|
1379
|
+
this.updateMpvStallTimer();
|
|
1072
1380
|
for (const listener of this.listeners) {
|
|
1073
1381
|
listener(state);
|
|
1074
1382
|
}
|
|
1075
1383
|
}
|
|
1384
|
+
updateMpvStallTimer() {
|
|
1385
|
+
if (!this.mpvMediaIsLive || this.backend !== 'mpv' || !this.state.ready || this.state.state !== 'playing' || this.mpvLiveRetuneInFlight) {
|
|
1386
|
+
clearTimeout(this.mpvStallTimer);
|
|
1387
|
+
this.mpvStallTimer = undefined;
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (this.mpvStallTimer)
|
|
1391
|
+
return;
|
|
1392
|
+
const session = this.mpvSessionId;
|
|
1393
|
+
this.mpvStallTimer = setTimeout(() => {
|
|
1394
|
+
this.mpvStallTimer = undefined;
|
|
1395
|
+
if (session !== this.mpvSessionId || !this.mpvMediaIsLive || !this.state.ready || this.state.state !== 'playing')
|
|
1396
|
+
return;
|
|
1397
|
+
void this.retireMpvStream({ ...this.state, state: 'error', ready: false, buffering: false,
|
|
1398
|
+
message: 'Live stream timed out without audio progress.' }, true).catch(() => undefined);
|
|
1399
|
+
}, Math.max(15_000, this.getSettings().tuneTimeoutSeconds * 1000));
|
|
1400
|
+
this.mpvStallTimer.unref();
|
|
1401
|
+
}
|
|
1076
1402
|
}
|
|
1077
1403
|
function airPlayWorkerPath() {
|
|
1078
1404
|
const currentPath = fileURLToPath(import.meta.url);
|
|
@@ -1133,15 +1459,6 @@ export function extractMpvTitle(metadata) {
|
|
|
1133
1459
|
}
|
|
1134
1460
|
return undefined;
|
|
1135
1461
|
}
|
|
1136
|
-
async function waitForStartupWindow(getProcess, ms, exitedError = () => new Error('Player exited before the stream became ready.')) {
|
|
1137
|
-
const started = Date.now();
|
|
1138
|
-
while (Date.now() - started < ms) {
|
|
1139
|
-
if (!getProcess()) {
|
|
1140
|
-
throw exitedError();
|
|
1141
|
-
}
|
|
1142
|
-
await delay(Math.min(100, ms - (Date.now() - started)));
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
1462
|
function appendPlayerDiagnostic(current, chunk) {
|
|
1146
1463
|
const combined = current + chunk;
|
|
1147
1464
|
return combined.length <= maxPlayerDiagnosticCharacters
|