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