@ciphore/radiocli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -3
- package/CONTRIBUTING.md +5 -2
- package/README.md +44 -15
- package/dist/player/airplay-discovery.js +174 -0
- package/dist/player/airplay-sender-health.js +98 -0
- package/dist/player/airplay-sender-patch.js +138 -0
- package/dist/player/airplay-worker-protocol.js +144 -0
- package/dist/player/airplay-worker.js +218 -0
- package/dist/player/backend-install.js +69 -4
- package/dist/player/player-controller.js +447 -20
- package/dist/storage/store.js +16 -6
- package/dist/ui/App.js +226 -20
- package/dist/ui/AppContent.js +13 -3
- package/dist/ui/airplay-settings.js +56 -0
- package/dist/ui/app-state.js +16 -1
- package/dist/ui/audio-output.js +42 -0
- package/dist/ui/page-footer.js +16 -2
- package/dist/ui/playback-footer.js +7 -0
- package/dist/ui/screen-items.js +3 -2
- package/dist/ui/screens/AirPlayCodeScreen.js +18 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +24 -0
- package/dist/ui/screens/HomeScreen.js +2 -1
- package/dist/ui/screens/SettingsScreen.js +14 -6
- package/dist/ui/use-app-input.js +46 -7
- package/dist/ui/use-command-executor.js +17 -1
- package/package.json +5 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const maxWorkerStartBytes = 16_384;
|
|
2
|
+
export const maxWorkerMessageBytes = 8192;
|
|
3
|
+
const maxWorkerTextBytes = 512;
|
|
4
|
+
const maxPasscodeBytes = 64;
|
|
5
|
+
export function encodeWorkerStart(start) {
|
|
6
|
+
return Buffer.from(JSON.stringify(start), 'utf8').toString('base64url');
|
|
7
|
+
}
|
|
8
|
+
export function decodeWorkerStart(encoded) {
|
|
9
|
+
const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
10
|
+
if (Buffer.byteLength(decoded, 'utf8') > maxWorkerStartBytes) {
|
|
11
|
+
throw new Error('AirPlay worker start payload is too large.');
|
|
12
|
+
}
|
|
13
|
+
return validateWorkerStart(JSON.parse(decoded));
|
|
14
|
+
}
|
|
15
|
+
export function serializeWorkerMessage(message) {
|
|
16
|
+
return `${JSON.stringify(message)}\n`;
|
|
17
|
+
}
|
|
18
|
+
export function parseWorkerMessage(line) {
|
|
19
|
+
if (!line.trim()) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (Buffer.byteLength(line, 'utf8') > maxWorkerMessageBytes) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return validateWorkerMessage(JSON.parse(line));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function validateWorkerStart(value) {
|
|
33
|
+
if (!isRecord(value) || !isRecord(value.device)) {
|
|
34
|
+
throw new Error('Invalid AirPlay worker start payload.');
|
|
35
|
+
}
|
|
36
|
+
const device = value.device;
|
|
37
|
+
const port = Number(device.port);
|
|
38
|
+
const txt = Array.isArray(device.txt)
|
|
39
|
+
? device.txt.slice(0, 64).map(item => boundedString(item, 'txt', 512)).filter(Boolean)
|
|
40
|
+
: [];
|
|
41
|
+
return {
|
|
42
|
+
streamUrl: boundedHttpUrl(value.streamUrl, 'streamUrl'),
|
|
43
|
+
stationName: boundedText(value.stationName, 'stationName', maxWorkerTextBytes),
|
|
44
|
+
volume: clampVolume(Number(value.volume)),
|
|
45
|
+
muted: typeof value.muted === 'boolean' ? value.muted : false,
|
|
46
|
+
device: {
|
|
47
|
+
id: boundedText(device.id, 'device.id', 256),
|
|
48
|
+
name: boundedText(device.name, 'device.name', 256),
|
|
49
|
+
host: safeHost(device.host),
|
|
50
|
+
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : invalid('device.port'),
|
|
51
|
+
txt,
|
|
52
|
+
requiresPassword: Boolean(device.requiresPassword),
|
|
53
|
+
airplay2: Boolean(device.airplay2)
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function validateWorkerMessage(value) {
|
|
58
|
+
if (!isRecord(value) || typeof value.type !== 'string') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (value.type === 'stop') {
|
|
62
|
+
return { type: 'stop' };
|
|
63
|
+
}
|
|
64
|
+
if (value.type === 'setVolume') {
|
|
65
|
+
return Number.isFinite(Number(value.volume)) ? { type: 'setVolume', volume: clampVolume(Number(value.volume)) } : null;
|
|
66
|
+
}
|
|
67
|
+
if (value.type === 'setMuted') {
|
|
68
|
+
return typeof value.muted === 'boolean' ? { type: 'setMuted', muted: value.muted } : null;
|
|
69
|
+
}
|
|
70
|
+
if (value.type === 'retune') {
|
|
71
|
+
try {
|
|
72
|
+
return {
|
|
73
|
+
type: 'retune',
|
|
74
|
+
streamUrl: boundedHttpUrl(value.streamUrl, 'streamUrl'),
|
|
75
|
+
stationName: boundedText(value.stationName, 'stationName', maxWorkerTextBytes)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (value.type === 'passcode') {
|
|
83
|
+
if (typeof value.code !== 'string' || Buffer.byteLength(value.code, 'utf8') > maxPasscodeBytes) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const code = cleanText(value.code, maxPasscodeBytes);
|
|
87
|
+
return code ? { type: 'passcode', code } : null;
|
|
88
|
+
}
|
|
89
|
+
if (value.type === 'ready' || value.type === 'playing' || value.type === 'retuned' || value.type === 'password-required' || value.type === 'stopped') {
|
|
90
|
+
return { type: value.type };
|
|
91
|
+
}
|
|
92
|
+
if (value.type === 'buffer') {
|
|
93
|
+
return { type: 'buffer', status: cleanText(value.status, 120) || 'unknown' };
|
|
94
|
+
}
|
|
95
|
+
if (value.type === 'error') {
|
|
96
|
+
return { type: 'error', message: cleanText(value.message, maxWorkerTextBytes) || 'AirPlay worker error.' };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function boundedString(value, field, maxBytes) {
|
|
101
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
102
|
+
return invalid(field);
|
|
103
|
+
}
|
|
104
|
+
const cleaned = cleanText(value, maxBytes);
|
|
105
|
+
return cleaned || invalid(field);
|
|
106
|
+
}
|
|
107
|
+
function boundedText(value, field, maxBytes) {
|
|
108
|
+
return boundedString(value, field, maxBytes);
|
|
109
|
+
}
|
|
110
|
+
function boundedHttpUrl(value, field) {
|
|
111
|
+
const streamUrl = boundedString(value, field, 4096);
|
|
112
|
+
const parsedUrl = new URL(streamUrl);
|
|
113
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
114
|
+
throw new Error('AirPlay streams must use http or https URLs.');
|
|
115
|
+
}
|
|
116
|
+
return streamUrl;
|
|
117
|
+
}
|
|
118
|
+
function cleanText(value, maxBytes) {
|
|
119
|
+
if (typeof value !== 'string') {
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
const withoutControls = value.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
123
|
+
let cleaned = withoutControls;
|
|
124
|
+
while (Buffer.byteLength(cleaned, 'utf8') > maxBytes) {
|
|
125
|
+
cleaned = cleaned.slice(0, -1);
|
|
126
|
+
}
|
|
127
|
+
return cleaned.trim();
|
|
128
|
+
}
|
|
129
|
+
function safeHost(value) {
|
|
130
|
+
const host = boundedString(value, 'device.host', 253).replace(/\.$/, '');
|
|
131
|
+
if (/[\s/\\]/.test(host) || !/^[A-Za-z0-9._:%-]+$/.test(host)) {
|
|
132
|
+
return invalid('device.host');
|
|
133
|
+
}
|
|
134
|
+
return host;
|
|
135
|
+
}
|
|
136
|
+
function clampVolume(volume) {
|
|
137
|
+
return Math.min(100, Math.max(0, Math.round(Number.isFinite(volume) ? volume : 70)));
|
|
138
|
+
}
|
|
139
|
+
function isRecord(value) {
|
|
140
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
141
|
+
}
|
|
142
|
+
function invalid(field) {
|
|
143
|
+
throw new Error(`Invalid AirPlay worker ${field}.`);
|
|
144
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
4
|
+
import { installAirPlaySenderPatch } from './airplay-sender-patch.js';
|
|
5
|
+
import { parseWorkerMessage, decodeWorkerStart, maxWorkerMessageBytes, serializeWorkerMessage } from './airplay-worker-protocol.js';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
// node-airtunes2 writes diagnostics with console.log; keep stdout reserved for JSON events.
|
|
8
|
+
console.log = (...args) => {
|
|
9
|
+
process.stderr.write(`${args.map(String).join(' ')}\n`);
|
|
10
|
+
};
|
|
11
|
+
const encodedStart = process.argv[2];
|
|
12
|
+
if (!encodedStart) {
|
|
13
|
+
emit({ type: 'error', message: 'Missing AirPlay worker start payload.' });
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
const AirTunes = loadAirTunesSender();
|
|
17
|
+
const start = decodeStartPayload(encodedStart);
|
|
18
|
+
const airtunes = new AirTunes();
|
|
19
|
+
let deviceKey = '';
|
|
20
|
+
let ffmpeg = null;
|
|
21
|
+
let muted = start.muted;
|
|
22
|
+
let volume = start.volume;
|
|
23
|
+
let retuning = false;
|
|
24
|
+
let stopping = false;
|
|
25
|
+
airtunes.on('device', (key, status) => {
|
|
26
|
+
if (typeof key === 'string') {
|
|
27
|
+
deviceKey = key;
|
|
28
|
+
}
|
|
29
|
+
if (status === 'ready') {
|
|
30
|
+
emit({ type: 'ready' });
|
|
31
|
+
}
|
|
32
|
+
else if (status === 'playing' || status === 'pair_success') {
|
|
33
|
+
emit({ type: 'playing' });
|
|
34
|
+
}
|
|
35
|
+
else if (status === 'need_password') {
|
|
36
|
+
emit({ type: 'password-required' });
|
|
37
|
+
}
|
|
38
|
+
else if (status === 'stopped') {
|
|
39
|
+
emit({ type: 'stopped' });
|
|
40
|
+
}
|
|
41
|
+
else if (status === 'error') {
|
|
42
|
+
emit({ type: 'error', message: 'AirPlay receiver reported an error.' });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
airtunes.on('buffer', status => {
|
|
46
|
+
const statusText = String(status);
|
|
47
|
+
emit({ type: 'buffer', status: statusText });
|
|
48
|
+
if (retuning && statusText === 'playing') {
|
|
49
|
+
retuning = false;
|
|
50
|
+
emit({ type: 'retuned' });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
airtunes.on('error', error => {
|
|
54
|
+
emit({ type: 'error', message: error instanceof Error ? error.message : String(error) });
|
|
55
|
+
});
|
|
56
|
+
const device = airtunes.add(start.device.host, {
|
|
57
|
+
port: start.device.port,
|
|
58
|
+
volume: muted ? 0 : volume,
|
|
59
|
+
txt: start.device.txt,
|
|
60
|
+
airplay2: start.device.airplay2,
|
|
61
|
+
forceAlac: true,
|
|
62
|
+
debug: false,
|
|
63
|
+
mode: start.device.airplay2 ? 2 : 0
|
|
64
|
+
});
|
|
65
|
+
deviceKey = device.key;
|
|
66
|
+
startFfmpeg(start.streamUrl);
|
|
67
|
+
let stdinBuffer = '';
|
|
68
|
+
process.stdin.on('data', chunk => {
|
|
69
|
+
stdinBuffer += chunk.toString('utf8');
|
|
70
|
+
if (Buffer.byteLength(stdinBuffer, 'utf8') > maxWorkerMessageBytes) {
|
|
71
|
+
emit({ type: 'error', message: 'AirPlay worker command exceeded the size limit.' });
|
|
72
|
+
stdinBuffer = '';
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let newlineIndex = stdinBuffer.indexOf('\n');
|
|
76
|
+
while (newlineIndex !== -1) {
|
|
77
|
+
const line = stdinBuffer.slice(0, newlineIndex);
|
|
78
|
+
stdinBuffer = stdinBuffer.slice(newlineIndex + 1);
|
|
79
|
+
newlineIndex = stdinBuffer.indexOf('\n');
|
|
80
|
+
const command = parseWorkerMessage(line);
|
|
81
|
+
if (command) {
|
|
82
|
+
handleCommand(command);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
process.once('SIGTERM', () => stop(0));
|
|
87
|
+
process.once('SIGINT', () => stop(0));
|
|
88
|
+
function handleCommand(command) {
|
|
89
|
+
if (command.type === 'stop') {
|
|
90
|
+
stop(0);
|
|
91
|
+
}
|
|
92
|
+
else if (command.type === 'setVolume') {
|
|
93
|
+
volume = clampVolume(command.volume);
|
|
94
|
+
setAirPlayVolume(muted ? 0 : volume);
|
|
95
|
+
}
|
|
96
|
+
else if (command.type === 'setMuted') {
|
|
97
|
+
muted = command.muted;
|
|
98
|
+
setAirPlayVolume(muted ? 0 : volume);
|
|
99
|
+
}
|
|
100
|
+
else if (command.type === 'retune') {
|
|
101
|
+
retuning = true;
|
|
102
|
+
airtunes.reset();
|
|
103
|
+
startFfmpeg(command.streamUrl);
|
|
104
|
+
}
|
|
105
|
+
else if (command.type === 'passcode') {
|
|
106
|
+
device.setPasscode?.(command.code);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function startFfmpeg(streamUrl) {
|
|
110
|
+
const previous = ffmpeg;
|
|
111
|
+
const child = spawn('ffmpeg', [
|
|
112
|
+
'-hide_banner',
|
|
113
|
+
'-loglevel',
|
|
114
|
+
'error',
|
|
115
|
+
// Recover from transient source drops without tearing down the AirPlay session.
|
|
116
|
+
// These are input options and must precede -i. -reconnect_streamed is essential for
|
|
117
|
+
// non-seekable radio; with no retry cap ffmpeg keeps trying until the station returns.
|
|
118
|
+
'-reconnect',
|
|
119
|
+
'1',
|
|
120
|
+
'-reconnect_on_network_error',
|
|
121
|
+
'1',
|
|
122
|
+
'-reconnect_streamed',
|
|
123
|
+
'1',
|
|
124
|
+
'-reconnect_delay_max',
|
|
125
|
+
'2',
|
|
126
|
+
'-i',
|
|
127
|
+
streamUrl,
|
|
128
|
+
'-f',
|
|
129
|
+
's16le',
|
|
130
|
+
'-ac',
|
|
131
|
+
'2',
|
|
132
|
+
'-ar',
|
|
133
|
+
'44100',
|
|
134
|
+
'pipe:1'
|
|
135
|
+
], {
|
|
136
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
137
|
+
});
|
|
138
|
+
ffmpeg = child;
|
|
139
|
+
if (previous && !previous.killed) {
|
|
140
|
+
previous.kill('SIGTERM');
|
|
141
|
+
}
|
|
142
|
+
child.stdout.on('data', chunk => {
|
|
143
|
+
if (ffmpeg === child && !stopping) {
|
|
144
|
+
airtunes.write(chunk);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
child.stderr.on('data', chunk => {
|
|
148
|
+
if (ffmpeg === child) {
|
|
149
|
+
process.stderr.write(chunk);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
child.once('error', error => {
|
|
153
|
+
if (ffmpeg !== child || stopping) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
emit({ type: 'error', message: error.message });
|
|
157
|
+
stop(1);
|
|
158
|
+
});
|
|
159
|
+
child.once('exit', code => {
|
|
160
|
+
if (ffmpeg !== child || stopping) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ffmpeg = null;
|
|
164
|
+
if (code !== 0) {
|
|
165
|
+
emit({ type: 'error', message: `ffmpeg exited with code ${code}` });
|
|
166
|
+
}
|
|
167
|
+
stop(code ?? 0);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function setAirPlayVolume(nextVolume) {
|
|
171
|
+
if (deviceKey) {
|
|
172
|
+
airtunes.setVolume(deviceKey, nextVolume);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function emit(event) {
|
|
176
|
+
process.stdout.write(serializeWorkerMessage(event));
|
|
177
|
+
}
|
|
178
|
+
function loadAirTunesSender() {
|
|
179
|
+
const health = airPlaySenderHealth();
|
|
180
|
+
if (!health.safe) {
|
|
181
|
+
emit({ type: 'error', message: health.message });
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
installAirPlaySenderPatch();
|
|
185
|
+
try {
|
|
186
|
+
return require('node-airtunes2');
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
emit({ type: 'error', message: 'AirPlay sender bridge could not be loaded.' });
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function decodeStartPayload(encoded) {
|
|
194
|
+
try {
|
|
195
|
+
return decodeWorkerStart(encoded);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
emit({ type: 'error', message: error instanceof Error ? error.message : 'Invalid AirPlay worker start payload.' });
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function stop(code) {
|
|
203
|
+
if (stopping) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
stopping = true;
|
|
207
|
+
if (ffmpeg && !ffmpeg.killed) {
|
|
208
|
+
ffmpeg.kill('SIGTERM');
|
|
209
|
+
}
|
|
210
|
+
ffmpeg = null;
|
|
211
|
+
airtunes.stopAll(() => {
|
|
212
|
+
emit({ type: 'stopped' });
|
|
213
|
+
process.exit(code);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function clampVolume(nextVolume) {
|
|
217
|
+
return Math.min(100, Math.max(0, Math.round(nextVolume)));
|
|
218
|
+
}
|
|
@@ -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
|
|
64
|
+
'npm_install=RadioCLI includes the AirPlay sender; native playback tools come from mpv and FFmpeg',
|
|
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
|
}
|