@ciphore/radiocli 0.2.3 → 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 +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- 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 +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- 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 +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- 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 +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- 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 +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- 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
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
3
|
import { dirname, join } from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
7
|
-
import { resolveCommand } from '
|
|
5
|
+
import { airPlayLocalDeviceLabel, detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
6
|
+
import { resolveCommand } from '../platform/executables.js';
|
|
7
|
+
import { airPlayPolicy, airPlaySetupRequiredMessage } from './airplay-policy.js';
|
|
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
11
|
import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
12
12
|
import { MpvIpcClient } from './mpv-ipc-client.js';
|
|
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;
|
|
17
|
+
const maxPlayerDiagnosticCharacters = 4096;
|
|
14
18
|
export class PlaybackOutputError extends Error {
|
|
15
19
|
constructor(message) {
|
|
16
20
|
super(message);
|
|
@@ -22,6 +26,7 @@ export function isPlaybackOutputError(error) {
|
|
|
22
26
|
}
|
|
23
27
|
export class PlayerController {
|
|
24
28
|
getSettings;
|
|
29
|
+
runtime;
|
|
25
30
|
process = null;
|
|
26
31
|
backend = null;
|
|
27
32
|
ipcPath = null;
|
|
@@ -42,17 +47,32 @@ export class PlayerController {
|
|
|
42
47
|
airPlayPasscodes = new Map();
|
|
43
48
|
currentAirPlayDevice = null;
|
|
44
49
|
currentAirPlayDeviceId = null;
|
|
50
|
+
selectedAirPlayDeviceId = null;
|
|
45
51
|
pendingAirPlayPasscode = null;
|
|
46
52
|
currentMpvMediaTitle = null;
|
|
47
53
|
metadataPollInFlight = false;
|
|
48
54
|
playbackStatePollInFlight = false;
|
|
55
|
+
mpvLiveRetuneInFlight = false;
|
|
56
|
+
mpvRetuneFailure;
|
|
49
57
|
pendingMpvVolume = null;
|
|
50
58
|
mpvVolumeFlush = null;
|
|
51
59
|
confirmedMpvVolume = 70;
|
|
52
60
|
mpvSessionId = 0;
|
|
61
|
+
playbackSessionId = 0;
|
|
53
62
|
stopPromise = null;
|
|
54
|
-
|
|
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;
|
|
73
|
+
constructor(getSettings, runtime = { platform: process.platform, arch: process.arch, env: process.env }) {
|
|
55
74
|
this.getSettings = getSettings;
|
|
75
|
+
this.runtime = runtime;
|
|
56
76
|
}
|
|
57
77
|
onChange(listener) {
|
|
58
78
|
this.listeners.add(listener);
|
|
@@ -66,11 +86,24 @@ export class PlayerController {
|
|
|
66
86
|
getState() {
|
|
67
87
|
return { ...this.state };
|
|
68
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
|
+
}
|
|
69
102
|
diagnostics() {
|
|
70
103
|
return {
|
|
71
104
|
backend: this.state.backend,
|
|
72
105
|
availableBackends: this.detectedBackends(),
|
|
73
|
-
preferredBackend: this.
|
|
106
|
+
preferredBackend: this.effectivePreferredBackend(),
|
|
74
107
|
active: Boolean(this.process),
|
|
75
108
|
streamUrl: this.state.streamUrl,
|
|
76
109
|
stationName: this.state.stationName,
|
|
@@ -81,43 +114,73 @@ export class PlayerController {
|
|
|
81
114
|
};
|
|
82
115
|
}
|
|
83
116
|
detectedBackends() {
|
|
84
|
-
return [...(this.availableBackends ?? [])];
|
|
117
|
+
return [...(this.availableBackends ?? [])].filter(backend => backend !== 'airplay' || this.airPlayPolicy().enabled);
|
|
85
118
|
}
|
|
86
119
|
refreshDetectedBackends() {
|
|
87
|
-
this.availableBackends = detectPlaybackBackends();
|
|
120
|
+
this.availableBackends = detectPlaybackBackends({ settings: this.getSettings(), platform: this.runtime.platform, env: this.runtime.env });
|
|
88
121
|
return this.detectedBackends();
|
|
89
122
|
}
|
|
90
|
-
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) {
|
|
91
143
|
const target = safeMediaTarget(url);
|
|
92
144
|
if (!target) {
|
|
93
145
|
throw new Error(`Station ${station.name} returned an unsupported stream URL.`);
|
|
94
146
|
}
|
|
95
147
|
url = target;
|
|
148
|
+
if (this.stopPromise) {
|
|
149
|
+
await this.stopPromise;
|
|
150
|
+
assertCurrent();
|
|
151
|
+
}
|
|
96
152
|
const backend = this.selectBackend();
|
|
153
|
+
if (options.startPaused && backend !== 'mpv')
|
|
154
|
+
throw new PlaybackOutputError('This output cannot start a station paused.');
|
|
97
155
|
if (!backend) {
|
|
98
|
-
await this.
|
|
156
|
+
await this.stopPlayback();
|
|
99
157
|
throw new PlaybackOutputError(this.playbackUnavailableMessage());
|
|
100
158
|
}
|
|
101
159
|
if (backend === 'airplay') {
|
|
102
160
|
const activeDevice = this.activeAirPlayDeviceForRetune();
|
|
103
161
|
if (activeDevice) {
|
|
104
162
|
await this.retuneAirPlay(url, station.name, activeDevice);
|
|
163
|
+
assertCurrent();
|
|
105
164
|
return;
|
|
106
165
|
}
|
|
107
166
|
let device;
|
|
108
167
|
try {
|
|
109
168
|
device = await this.resolveAirPlayDevice();
|
|
169
|
+
assertCurrent();
|
|
110
170
|
}
|
|
111
171
|
catch (error) {
|
|
172
|
+
assertCurrent();
|
|
112
173
|
const message = error instanceof Error ? error.message : 'Could not resolve AirPlay receiver.';
|
|
113
174
|
this.setState({ ...this.state, backend, state: 'error', message, ready: false });
|
|
114
175
|
throw error;
|
|
115
176
|
}
|
|
116
177
|
if (this.canRetuneAirPlay(device)) {
|
|
117
178
|
await this.retuneAirPlay(url, station.name, device);
|
|
179
|
+
assertCurrent();
|
|
118
180
|
return;
|
|
119
181
|
}
|
|
120
|
-
await this.
|
|
182
|
+
await this.stopPlayback();
|
|
183
|
+
assertCurrent();
|
|
121
184
|
this.backend = backend;
|
|
122
185
|
this.currentAirPlayDevice = device;
|
|
123
186
|
this.currentAirPlayDeviceId = device.id;
|
|
@@ -133,6 +196,7 @@ export class PlayerController {
|
|
|
133
196
|
});
|
|
134
197
|
this.setState({ ...this.state, airPlayDeviceName: device.name });
|
|
135
198
|
const result = await this.playWithAirPlay(url, station.name, device);
|
|
199
|
+
assertCurrent();
|
|
136
200
|
if (result === 'password-required') {
|
|
137
201
|
if (this.pendingAirPlayPasscode) {
|
|
138
202
|
return;
|
|
@@ -161,8 +225,12 @@ export class PlayerController {
|
|
|
161
225
|
});
|
|
162
226
|
return;
|
|
163
227
|
}
|
|
164
|
-
await this.
|
|
228
|
+
await this.stopPlayback();
|
|
229
|
+
assertCurrent();
|
|
165
230
|
this.backend = backend;
|
|
231
|
+
this.stationIsRadio = station.provider !== 'playlist';
|
|
232
|
+
this.mpvMediaIsLive = false;
|
|
233
|
+
this.mpvProgress = null;
|
|
166
234
|
this.setState({
|
|
167
235
|
backend,
|
|
168
236
|
state: 'loading',
|
|
@@ -174,20 +242,67 @@ export class PlayerController {
|
|
|
174
242
|
ready: false
|
|
175
243
|
});
|
|
176
244
|
if (backend === 'mpv') {
|
|
177
|
-
this.playWithMpv(url, station.name);
|
|
178
|
-
|
|
245
|
+
this.playWithMpv(url, station.name, undefined, options.startPaused);
|
|
246
|
+
try {
|
|
247
|
+
await this.waitForReady(backend);
|
|
248
|
+
assertCurrent();
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
assertCurrent();
|
|
252
|
+
if (!this.shouldRetryMpvWithAlsa(error))
|
|
253
|
+
throw error;
|
|
254
|
+
await this.stopPlayback();
|
|
255
|
+
assertCurrent();
|
|
256
|
+
this.backend = backend;
|
|
257
|
+
this.setState({
|
|
258
|
+
backend,
|
|
259
|
+
state: 'loading',
|
|
260
|
+
message: `Opening ${station.name}`,
|
|
261
|
+
volume: this.getSettings().volume,
|
|
262
|
+
muted: false,
|
|
263
|
+
stationName: station.name,
|
|
264
|
+
streamUrl: url,
|
|
265
|
+
ready: false
|
|
266
|
+
});
|
|
267
|
+
this.playWithMpv(url, station.name, 'alsa,', options.startPaused);
|
|
268
|
+
await this.waitForReady(backend);
|
|
269
|
+
assertCurrent();
|
|
270
|
+
}
|
|
179
271
|
}
|
|
180
272
|
else if (backend === 'ffplay') {
|
|
181
|
-
this.playWithFfplay(url);
|
|
182
|
-
|
|
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();
|
|
183
297
|
}
|
|
184
298
|
else if (backend === 'vlc') {
|
|
185
299
|
this.playWithVlc(url);
|
|
186
300
|
await this.waitForReady(backend);
|
|
301
|
+
assertCurrent();
|
|
187
302
|
}
|
|
188
303
|
this.setState({
|
|
189
304
|
backend,
|
|
190
|
-
state: 'playing',
|
|
305
|
+
state: options.startPaused ? 'paused' : 'playing',
|
|
191
306
|
message: station.name,
|
|
192
307
|
volume: this.getSettings().volume,
|
|
193
308
|
muted: false,
|
|
@@ -209,6 +324,9 @@ export class PlayerController {
|
|
|
209
324
|
return unsupported;
|
|
210
325
|
}
|
|
211
326
|
if (this.backend === 'mpv') {
|
|
327
|
+
if (this.state.state === 'paused') {
|
|
328
|
+
return this.resumeMpvAtLiveEdge();
|
|
329
|
+
}
|
|
212
330
|
try {
|
|
213
331
|
await this.sendMpv({ command: ['cycle', 'pause'] });
|
|
214
332
|
}
|
|
@@ -244,8 +362,59 @@ export class PlayerController {
|
|
|
244
362
|
return { ok: false, message: 'RadioCLI has no paused station to resume.' };
|
|
245
363
|
return this.togglePause();
|
|
246
364
|
}
|
|
247
|
-
|
|
365
|
+
async resumeMpvAtLiveEdge() {
|
|
366
|
+
if (this.mpvLiveRetuneInFlight)
|
|
367
|
+
return { ok: true, message: 'Reconnecting at the live broadcast.' };
|
|
368
|
+
const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null;
|
|
369
|
+
if (!url)
|
|
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.' };
|
|
375
|
+
this.mpvLiveRetuneInFlight = true;
|
|
376
|
+
this.mpvRetuneFailure = undefined;
|
|
377
|
+
try {
|
|
378
|
+
// Reloading the same URL discards mpv's delayed live buffer and opens a
|
|
379
|
+
// fresh stream connection. This keeps radio pause/play semantics at the
|
|
380
|
+
// live edge instead of behaving like time-shifted on-demand audio.
|
|
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.');
|
|
393
|
+
}
|
|
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
|
+
}
|
|
399
|
+
return { ok: false, message: 'mpv could not reconnect the paused station at the live edge.' };
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
if (session === this.mpvSessionId)
|
|
403
|
+
this.mpvLiveRetuneInFlight = false;
|
|
404
|
+
}
|
|
405
|
+
if (session !== this.mpvSessionId)
|
|
406
|
+
return { ok: false, message: 'Playback changed during resume.' };
|
|
407
|
+
this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI';
|
|
408
|
+
this.emitMetadata({ updatedAt: new Date().toISOString() });
|
|
409
|
+
this.setState({ ...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString() });
|
|
410
|
+
return { ok: true, message: 'Reconnected at the live broadcast.' };
|
|
411
|
+
}
|
|
412
|
+
setVolume(volume, options = {}) {
|
|
248
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
|
+
}
|
|
249
418
|
const unsupported = this.unsupportedFfplayControl();
|
|
250
419
|
if (unsupported) {
|
|
251
420
|
return Promise.resolve(unsupported);
|
|
@@ -286,12 +455,41 @@ export class PlayerController {
|
|
|
286
455
|
this.setState({ ...this.state, muted });
|
|
287
456
|
return { ok: true };
|
|
288
457
|
}
|
|
289
|
-
async setMuted(muted) {
|
|
290
|
-
if (this.state.
|
|
458
|
+
async setMuted(muted, options = {}) {
|
|
459
|
+
if (options.allowStopped && !this.process && ['idle', 'stopped'].includes(this.state.state)) {
|
|
460
|
+
this.setState({ ...this.state, muted });
|
|
291
461
|
return { ok: true };
|
|
292
|
-
|
|
462
|
+
}
|
|
463
|
+
const unsupported = this.unsupportedFfplayControl();
|
|
464
|
+
if (unsupported)
|
|
465
|
+
return this.state.muted === muted ? { ok: true } : unsupported;
|
|
466
|
+
// mpv can inherit mute=yes from its configuration, so the controller's
|
|
467
|
+
// startup default is not authoritative. Explicit setters must always reach
|
|
468
|
+
// the backend even when the requested value matches our local state.
|
|
469
|
+
if (this.backend === 'mpv') {
|
|
470
|
+
try {
|
|
471
|
+
await this.sendMpv({ command: ['set_property', 'mute', muted] });
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return { ok: false, message: 'mpv did not acknowledge the mute change.' };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else if (this.backend === 'airplay') {
|
|
478
|
+
if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
|
|
479
|
+
return { ok: false, message: 'The AirPlay worker is not available.' };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
else if (this.state.muted === muted) {
|
|
483
|
+
return { ok: true };
|
|
484
|
+
}
|
|
485
|
+
this.setState({ ...this.state, muted });
|
|
486
|
+
return { ok: true };
|
|
293
487
|
}
|
|
294
488
|
stop() {
|
|
489
|
+
this.playRequestId++;
|
|
490
|
+
return this.stopPlayback();
|
|
491
|
+
}
|
|
492
|
+
stopPlayback() {
|
|
295
493
|
if (this.stopPromise)
|
|
296
494
|
return this.stopPromise;
|
|
297
495
|
const operation = this.performStop();
|
|
@@ -302,6 +500,7 @@ export class PlayerController {
|
|
|
302
500
|
}
|
|
303
501
|
async performStop() {
|
|
304
502
|
const child = this.process;
|
|
503
|
+
this.mpvMediaIsLive = false;
|
|
305
504
|
const startupInProgress = this.state.state === 'loading' && !this.state.ready;
|
|
306
505
|
this.stopMpvPolling();
|
|
307
506
|
this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
|
|
@@ -334,7 +533,11 @@ export class PlayerController {
|
|
|
334
533
|
ready: false
|
|
335
534
|
});
|
|
336
535
|
}
|
|
536
|
+
airPlayPolicy() { return airPlayPolicy(this.getSettings(), this.runtime); }
|
|
337
537
|
submitAirPlayPasscode(code) {
|
|
538
|
+
const policy = this.airPlayPolicy();
|
|
539
|
+
if (!policy.enabled)
|
|
540
|
+
return { ok: false, message: policy.message };
|
|
338
541
|
if (this.backend !== 'airplay') {
|
|
339
542
|
return { ok: false, message: 'No active AirPlay playback is waiting for a code.' };
|
|
340
543
|
}
|
|
@@ -353,15 +556,34 @@ export class PlayerController {
|
|
|
353
556
|
return { ok: true, message };
|
|
354
557
|
}
|
|
355
558
|
async refreshAirPlayDevices() {
|
|
356
|
-
|
|
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 });
|
|
357
563
|
return [...this.availableAirPlayDevices];
|
|
358
564
|
}
|
|
359
565
|
detectedAirPlayDevices() {
|
|
360
566
|
return [...this.availableAirPlayDevices];
|
|
361
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
|
+
}
|
|
362
584
|
selectBackend() {
|
|
363
|
-
const preferred = this.
|
|
364
|
-
const backends = this.availableBackends
|
|
585
|
+
const preferred = this.effectivePreferredBackend();
|
|
586
|
+
const backends = this.availableBackends ? this.detectedBackends() : this.refreshDetectedBackends();
|
|
365
587
|
if (preferred === 'mpv') {
|
|
366
588
|
return backends.includes('mpv') ? 'mpv' : null;
|
|
367
589
|
}
|
|
@@ -388,25 +610,41 @@ export class PlayerController {
|
|
|
388
610
|
playbackUnavailableMessage() {
|
|
389
611
|
const preferred = this.getSettings().preferredBackend;
|
|
390
612
|
if (preferred === 'airplay') {
|
|
391
|
-
|
|
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}`;
|
|
392
617
|
}
|
|
393
618
|
if (preferred === 'mpv' || preferred === 'ffplay') {
|
|
394
619
|
return `Preferred playback backend ${preferred} is unavailable. ${playbackBackendInstallHint()}`;
|
|
395
620
|
}
|
|
396
621
|
return `No playback backend found. ${playbackBackendInstallHint()}`;
|
|
397
622
|
}
|
|
398
|
-
playWithMpv(url, initialTitle) {
|
|
399
|
-
this.
|
|
623
|
+
playWithMpv(url, initialTitle, recoveredAudioOutput, startPaused = false) {
|
|
624
|
+
this.playbackSessionId += 1;
|
|
625
|
+
this.ipcPath = mpvIpcPath();
|
|
400
626
|
this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
|
|
401
627
|
this.mpvSessionId += 1;
|
|
402
628
|
this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
|
|
403
629
|
this.pendingMpvVolume = null;
|
|
404
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 });
|
|
405
636
|
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
406
637
|
'--no-video',
|
|
407
|
-
'--
|
|
638
|
+
'--msg-level=all=warn',
|
|
408
639
|
'--force-window=no',
|
|
409
|
-
|
|
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,
|
|
645
|
+
...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
|
|
646
|
+
? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
|
|
647
|
+
: []),
|
|
410
648
|
`--force-media-title=${this.currentMpvMediaTitle}`,
|
|
411
649
|
`--volume=${this.getSettings().volume}`,
|
|
412
650
|
`--input-ipc-server=${this.ipcPath}`,
|
|
@@ -416,18 +654,22 @@ export class PlayerController {
|
|
|
416
654
|
});
|
|
417
655
|
this.wireProcess();
|
|
418
656
|
}
|
|
419
|
-
playWithFfplay(url) {
|
|
420
|
-
this.
|
|
657
|
+
playWithFfplay(url, plainHttp = false) {
|
|
658
|
+
this.playbackSessionId += 1;
|
|
659
|
+
this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-stats', ...ffmpegInputArguments(url, { plainHttp }), '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
421
660
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
422
661
|
});
|
|
423
662
|
this.wireProcess();
|
|
663
|
+
return new Promise(resolve => this.process.once('close', resolve));
|
|
424
664
|
}
|
|
425
665
|
playWithVlc(url) {
|
|
666
|
+
this.playbackSessionId += 1;
|
|
426
667
|
// VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
|
|
427
668
|
// binary (resolved from app bundles when off PATH) with a dummy interface.
|
|
428
669
|
const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
|
|
429
670
|
const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2);
|
|
430
|
-
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'] });
|
|
431
673
|
this.wireProcess();
|
|
432
674
|
}
|
|
433
675
|
canRetuneAirPlay(device) {
|
|
@@ -510,7 +752,7 @@ export class PlayerController {
|
|
|
510
752
|
throw new PlaybackOutputError('Selected AirPlay receiver was not found. Refresh AirPlay receivers in Settings.');
|
|
511
753
|
}
|
|
512
754
|
if (device.local) {
|
|
513
|
-
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.`);
|
|
514
756
|
}
|
|
515
757
|
return device;
|
|
516
758
|
}
|
|
@@ -650,24 +892,84 @@ export class PlayerController {
|
|
|
650
892
|
}
|
|
651
893
|
wireProcess() {
|
|
652
894
|
const child = this.process;
|
|
653
|
-
|
|
895
|
+
const backend = this.backend;
|
|
896
|
+
if (!child || !backend) {
|
|
654
897
|
return;
|
|
655
898
|
}
|
|
899
|
+
this.lastPlayerExit = null;
|
|
900
|
+
this.activePlayerDiagnostic = '';
|
|
901
|
+
this.fallbackAudioStarted = false;
|
|
902
|
+
let progressBuffer = '';
|
|
903
|
+
let vlcAudioOutput = false;
|
|
904
|
+
let vlcDecoderStarted = false;
|
|
905
|
+
let diagnostic = '';
|
|
906
|
+
let exitRecord;
|
|
907
|
+
child.stdout.on('data', chunk => {
|
|
908
|
+
diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
|
|
909
|
+
if (this.process === child)
|
|
910
|
+
this.activePlayerDiagnostic = diagnostic;
|
|
911
|
+
});
|
|
912
|
+
child.stderr.on('data', chunk => {
|
|
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
|
+
}
|
|
933
|
+
});
|
|
656
934
|
// Local players can write diagnostics indefinitely. Drain both pipes so a
|
|
657
935
|
// full OS pipe buffer can never stall playback.
|
|
658
936
|
child.stdout.resume?.();
|
|
659
937
|
child.stderr.resume?.();
|
|
938
|
+
child.once('close', () => {
|
|
939
|
+
if (exitRecord && this.lastPlayerExit === exitRecord)
|
|
940
|
+
exitRecord.diagnostic = diagnostic;
|
|
941
|
+
});
|
|
660
942
|
child.on('error', error => {
|
|
943
|
+
diagnostic = appendPlayerDiagnostic(diagnostic, error.message);
|
|
944
|
+
if (this.process !== child)
|
|
945
|
+
return;
|
|
946
|
+
this.lastPlayerExit = { backend, code: null, signal: null, diagnostic, spawnError: true };
|
|
947
|
+
this.rejectPendingAirPlayReady(error);
|
|
948
|
+
this.rejectPendingAirPlayRetune(error);
|
|
949
|
+
if (backend === 'airplay') {
|
|
950
|
+
this.currentAirPlayDevice = null;
|
|
951
|
+
this.currentAirPlayDeviceId = null;
|
|
952
|
+
this.pendingAirPlayPasscode = null;
|
|
953
|
+
this.airPlaySessionEstablished = false;
|
|
954
|
+
}
|
|
955
|
+
this.process = null;
|
|
956
|
+
this.stopMpvPolling();
|
|
957
|
+
this.cleanupIpc();
|
|
661
958
|
this.setState({
|
|
662
959
|
...this.state,
|
|
663
|
-
backend
|
|
960
|
+
backend,
|
|
664
961
|
state: 'error',
|
|
665
962
|
message: error.message,
|
|
666
963
|
ready: false
|
|
667
964
|
});
|
|
668
965
|
});
|
|
669
|
-
child.on('exit', code => {
|
|
966
|
+
child.on('exit', (code, signal) => {
|
|
670
967
|
if (this.process === child) {
|
|
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 };
|
|
671
973
|
this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
|
|
672
974
|
if (this.backend === 'airplay') {
|
|
673
975
|
this.currentAirPlayDevice = null;
|
|
@@ -681,8 +983,9 @@ export class PlayerController {
|
|
|
681
983
|
this.setState({
|
|
682
984
|
...this.state,
|
|
683
985
|
backend: this.backend ?? 'none',
|
|
684
|
-
state: code
|
|
685
|
-
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,
|
|
686
989
|
ready: false
|
|
687
990
|
});
|
|
688
991
|
}
|
|
@@ -789,18 +1092,26 @@ export class PlayerController {
|
|
|
789
1092
|
async waitForReady(backend) {
|
|
790
1093
|
const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
|
|
791
1094
|
const started = Date.now();
|
|
1095
|
+
const child = this.process;
|
|
1096
|
+
const playbackSessionId = this.playbackSessionId;
|
|
1097
|
+
const mpvClient = this.mpvIpcClient;
|
|
1098
|
+
const wasSuperseded = () => this.playbackSessionId !== playbackSessionId;
|
|
1099
|
+
const isCurrent = () => !wasSuperseded() && this.process === child;
|
|
792
1100
|
while (Date.now() - started < timeoutMs) {
|
|
793
|
-
if (
|
|
794
|
-
throw new Error(
|
|
1101
|
+
if (backend === 'mpv' && this.mpvLiveRetuneInFlight && this.mpvRetuneFailure)
|
|
1102
|
+
throw new Error(this.mpvRetuneFailure);
|
|
1103
|
+
if (wasSuperseded()) {
|
|
1104
|
+
throw new Error(`${backend} playback request was superseded.`);
|
|
795
1105
|
}
|
|
796
|
-
if (
|
|
797
|
-
|
|
798
|
-
return;
|
|
1106
|
+
if (!child || this.process !== child) {
|
|
1107
|
+
throw this.playerExitedBeforeReady(backend);
|
|
799
1108
|
}
|
|
800
|
-
if (this.
|
|
1109
|
+
if ((backend === 'ffplay' || backend === 'vlc') && this.fallbackAudioStarted)
|
|
1110
|
+
return;
|
|
1111
|
+
if (mpvClient) {
|
|
801
1112
|
try {
|
|
802
|
-
await
|
|
803
|
-
if (await this.hasMpvAudioStarted()) {
|
|
1113
|
+
await mpvClient.query({ command: ['get_property', 'path'] });
|
|
1114
|
+
if (await this.hasMpvAudioStarted(mpvClient) && isCurrent()) {
|
|
804
1115
|
return;
|
|
805
1116
|
}
|
|
806
1117
|
}
|
|
@@ -810,18 +1121,107 @@ export class PlayerController {
|
|
|
810
1121
|
}
|
|
811
1122
|
await delay(150);
|
|
812
1123
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1124
|
+
if (wasSuperseded()) {
|
|
1125
|
+
throw new Error(`${backend} playback request was superseded.`);
|
|
1126
|
+
}
|
|
1127
|
+
if (this.process !== child)
|
|
1128
|
+
throw this.playerExitedBeforeReady(backend);
|
|
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);
|
|
1139
|
+
}
|
|
1140
|
+
shouldRetryMpvWithAlsa(error) {
|
|
1141
|
+
return this.runtime.platform === 'linux' &&
|
|
1142
|
+
(this.runtime.arch === 'arm' || this.runtime.arch === 'arm64') &&
|
|
1143
|
+
!this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT &&
|
|
1144
|
+
isAudioOutputInitializationFailure(this.lastPlayerExit?.diagnostic ?? '') &&
|
|
1145
|
+
isPlaybackOutputError(error);
|
|
1146
|
+
}
|
|
1147
|
+
playerExitedBeforeReady(backend) {
|
|
1148
|
+
const exit = this.lastPlayerExit?.backend === backend ? this.lastPlayerExit : null;
|
|
1149
|
+
const reason = exit
|
|
1150
|
+
? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}`
|
|
1151
|
+
: 'before its exit status was available';
|
|
1152
|
+
const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? '');
|
|
1153
|
+
const inputFailure = backend === 'ffplay' ? ffmpegInputFailure(exit?.diagnostic ?? '') : undefined;
|
|
1154
|
+
if (inputFailure)
|
|
1155
|
+
return new PlaybackOutputError(inputFailure);
|
|
1156
|
+
const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`;
|
|
1157
|
+
return isBackendInitializationFailure(backend, exit, diagnostic)
|
|
1158
|
+
? new PlaybackOutputError(message)
|
|
1159
|
+
: new Error(message);
|
|
1160
|
+
}
|
|
1161
|
+
async hasMpvAudioStarted(client) {
|
|
817
1162
|
const [timePos, audioPts] = await Promise.all([
|
|
818
|
-
|
|
819
|
-
|
|
1163
|
+
client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
|
|
1164
|
+
client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
|
|
820
1165
|
]);
|
|
821
|
-
|
|
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;
|
|
822
1180
|
}
|
|
823
1181
|
startMpvMetadataPolling() {
|
|
824
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);
|
|
825
1225
|
this.metadataTimer = setInterval(() => {
|
|
826
1226
|
if (!this.metadataPollInFlight)
|
|
827
1227
|
void this.pollMpvMetadata();
|
|
@@ -832,8 +1232,11 @@ export class PlayerController {
|
|
|
832
1232
|
}, 500);
|
|
833
1233
|
void this.pollMpvMetadata();
|
|
834
1234
|
void this.syncMpvPlaybackState();
|
|
1235
|
+
this.updateMpvStallTimer();
|
|
835
1236
|
}
|
|
836
1237
|
stopMpvPolling() {
|
|
1238
|
+
clearTimeout(this.mpvStallTimer);
|
|
1239
|
+
this.mpvStallTimer = undefined;
|
|
837
1240
|
if (this.metadataTimer) {
|
|
838
1241
|
clearInterval(this.metadataTimer);
|
|
839
1242
|
this.metadataTimer = null;
|
|
@@ -843,27 +1246,59 @@ export class PlayerController {
|
|
|
843
1246
|
this.playbackStateTimer = null;
|
|
844
1247
|
}
|
|
845
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
|
+
}
|
|
846
1261
|
async pollMpvMetadata() {
|
|
847
1262
|
if (this.backend !== 'mpv' || !this.process) {
|
|
848
1263
|
return;
|
|
849
1264
|
}
|
|
1265
|
+
const client = this.mpvIpcClient;
|
|
1266
|
+
const session = this.mpvSessionId;
|
|
1267
|
+
if (!client)
|
|
1268
|
+
return;
|
|
850
1269
|
this.metadataPollInFlight = true;
|
|
851
1270
|
try {
|
|
852
|
-
const metadata = await
|
|
853
|
-
|
|
854
|
-
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
|
+
}
|
|
855
1283
|
this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
|
|
856
1284
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
await this.setMpvMediaTitle(title);
|
|
860
|
-
this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
|
|
861
|
-
}
|
|
1285
|
+
if (metadata)
|
|
1286
|
+
await this.applyMpvMetadata(metadata, client, session);
|
|
862
1287
|
}
|
|
863
1288
|
finally {
|
|
864
|
-
this.
|
|
1289
|
+
if (session === this.mpvSessionId)
|
|
1290
|
+
this.metadataPollInFlight = false;
|
|
865
1291
|
}
|
|
866
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
|
+
}
|
|
867
1302
|
async setMpvMediaTitle(title) {
|
|
868
1303
|
const cleaned = cleanMediaTitle(title);
|
|
869
1304
|
if (!cleaned || cleaned === this.currentMpvMediaTitle) {
|
|
@@ -878,22 +1313,35 @@ export class PlayerController {
|
|
|
878
1313
|
}
|
|
879
1314
|
if (this.playbackStatePollInFlight)
|
|
880
1315
|
return false;
|
|
1316
|
+
const client = this.mpvIpcClient;
|
|
1317
|
+
const session = this.mpvSessionId;
|
|
1318
|
+
if (!client)
|
|
1319
|
+
return false;
|
|
881
1320
|
this.playbackStatePollInFlight = true;
|
|
882
1321
|
try {
|
|
883
|
-
const paused = await
|
|
884
|
-
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) {
|
|
885
1324
|
return false;
|
|
886
1325
|
}
|
|
887
|
-
|
|
888
|
-
if (this.state.state !== state) {
|
|
889
|
-
this.setState({ ...this.state, state });
|
|
890
|
-
}
|
|
891
|
-
return true;
|
|
1326
|
+
return this.applyMpvPause(paused);
|
|
892
1327
|
}
|
|
893
1328
|
finally {
|
|
894
|
-
this.
|
|
1329
|
+
if (session === this.mpvSessionId)
|
|
1330
|
+
this.playbackStatePollInFlight = false;
|
|
895
1331
|
}
|
|
896
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
|
+
}
|
|
897
1345
|
emitMetadata(metadata) {
|
|
898
1346
|
for (const listener of this.metadataListeners) {
|
|
899
1347
|
try {
|
|
@@ -910,6 +1358,11 @@ export class PlayerController {
|
|
|
910
1358
|
this.mpvIpcClient = null;
|
|
911
1359
|
this.pendingMpvVolume = null;
|
|
912
1360
|
this.mpvVolumeFlush = null;
|
|
1361
|
+
this.metadataPollInFlight = false;
|
|
1362
|
+
this.playbackStatePollInFlight = false;
|
|
1363
|
+
this.mpvLiveRetuneInFlight = false;
|
|
1364
|
+
this.mpvRetuneFailure = undefined;
|
|
1365
|
+
this.mpvObserved = false;
|
|
913
1366
|
if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
|
|
914
1367
|
try {
|
|
915
1368
|
unlinkSync(this.ipcPath);
|
|
@@ -923,16 +1376,29 @@ export class PlayerController {
|
|
|
923
1376
|
}
|
|
924
1377
|
setState(state) {
|
|
925
1378
|
this.state = state;
|
|
1379
|
+
this.updateMpvStallTimer();
|
|
926
1380
|
for (const listener of this.listeners) {
|
|
927
1381
|
listener(state);
|
|
928
1382
|
}
|
|
929
1383
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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();
|
|
934
1401
|
}
|
|
935
|
-
return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
|
|
936
1402
|
}
|
|
937
1403
|
function airPlayWorkerPath() {
|
|
938
1404
|
const currentPath = fileURLToPath(import.meta.url);
|
|
@@ -993,14 +1459,35 @@ export function extractMpvTitle(metadata) {
|
|
|
993
1459
|
}
|
|
994
1460
|
return undefined;
|
|
995
1461
|
}
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1462
|
+
function appendPlayerDiagnostic(current, chunk) {
|
|
1463
|
+
const combined = current + chunk;
|
|
1464
|
+
return combined.length <= maxPlayerDiagnosticCharacters
|
|
1465
|
+
? combined
|
|
1466
|
+
: combined.slice(-maxPlayerDiagnosticCharacters);
|
|
1467
|
+
}
|
|
1468
|
+
function concisePlayerDiagnostic(value) {
|
|
1469
|
+
const lines = value
|
|
1470
|
+
.split(/\r?\n/)
|
|
1471
|
+
.map(line => sanitizeTerminalText(line.replace(/https?:\/\/\S+/gi, '[stream URL]')))
|
|
1472
|
+
.filter((line) => Boolean(line))
|
|
1473
|
+
.slice(-3)
|
|
1474
|
+
.join(' · ')
|
|
1475
|
+
.slice(0, 700);
|
|
1476
|
+
return lines;
|
|
1477
|
+
}
|
|
1478
|
+
function isAudioOutputInitializationFailure(value) {
|
|
1479
|
+
return /audio output initialization failed|could not open\/initialize audio device|failed to initialize audio driver/i.test(value);
|
|
1480
|
+
}
|
|
1481
|
+
function isBackendInitializationFailure(backend, exit, diagnostic) {
|
|
1482
|
+
if (isAudioOutputInitializationFailure(diagnostic))
|
|
1483
|
+
return true;
|
|
1484
|
+
if (exit?.spawnError)
|
|
1485
|
+
return true;
|
|
1486
|
+
if (/unknown (?:command line )?option|error initializing|failed to (?:create|connect to) .*(?:socket|pipe)/i.test(diagnostic))
|
|
1487
|
+
return true;
|
|
1488
|
+
// mpv reserves status 1 for player initialization and invalid options. A
|
|
1489
|
+
// stream that could not be played uses status 2 and remains skippable.
|
|
1490
|
+
return backend === 'mpv' && exit?.code === 1;
|
|
1004
1491
|
}
|
|
1005
1492
|
function cleanMetadataTitle(value) {
|
|
1006
1493
|
const normalized = cleanMediaTitle(value);
|