@ciphore/radiocli 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/storage/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { platformPaths } from '../platform/paths.js';
|
|
2
2
|
import { assertStorageWritable } from '../platform/storage.js';
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
@@ -8,37 +8,26 @@ import { z } from 'zod';
|
|
|
8
8
|
import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
|
|
9
9
|
import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
|
|
10
10
|
import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
name: z.string(),
|
|
16
|
-
country: z.string().optional(),
|
|
17
|
-
countryCode: z.string().optional(),
|
|
18
|
-
state: z.string().optional(),
|
|
19
|
-
city: z.string().optional(),
|
|
20
|
-
language: z.string().optional(),
|
|
21
|
-
languageCodes: z.array(z.string()).optional(),
|
|
22
|
-
tags: z.array(z.string()),
|
|
23
|
-
codec: z.string().optional(),
|
|
24
|
-
bitrate: z.number().optional(),
|
|
25
|
-
homepage: z.string().optional(),
|
|
26
|
-
favicon: z.string().optional(),
|
|
27
|
-
streamUrl: z.string().optional(),
|
|
28
|
-
clickCount: z.number().optional(),
|
|
29
|
-
votes: z.number().optional(),
|
|
30
|
-
latitude: z.number().optional(),
|
|
31
|
-
longitude: z.number().optional(),
|
|
32
|
-
distanceKm: z.number().optional(),
|
|
33
|
-
hls: z.boolean().optional(),
|
|
34
|
-
lastCheckedOk: z.boolean().optional()
|
|
35
|
-
});
|
|
11
|
+
import { migrateStationIdentity } from './station-identity.js';
|
|
12
|
+
import { stationSchema } from './station-schema.js';
|
|
13
|
+
import { acquireFileLock } from './file-lock.js';
|
|
14
|
+
import { replaceFileSync } from './replace-file.js';
|
|
36
15
|
const defaultMediaKeys = {
|
|
37
16
|
previous: [],
|
|
38
17
|
playPause: [],
|
|
39
18
|
next: []
|
|
40
19
|
};
|
|
41
20
|
const MAX_ALARMS = 500;
|
|
21
|
+
// Keep the shared library readable by pre-scene releases whose background MCP
|
|
22
|
+
// workers quarantine an entire library when they see an unfamiliar enum value.
|
|
23
|
+
// Crossfade is the last style understood by those releases. New styles live in
|
|
24
|
+
// a separate preference file that old writers never read or overwrite.
|
|
25
|
+
const sharedLibraryReceiverStyles = new Set(receiverStyleNames.slice(0, receiverStyleNames.indexOf('crossfade') + 1));
|
|
26
|
+
const receiverPreferenceSchema = z.object({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)),
|
|
29
|
+
libraryReceiverStyle: z.literal('ultracode')
|
|
30
|
+
});
|
|
42
31
|
const settingsSchema = z.object({
|
|
43
32
|
theme: z.enum(themeNames).default('green'),
|
|
44
33
|
receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
|
|
@@ -49,6 +38,7 @@ const settingsSchema = z.object({
|
|
|
49
38
|
shareDirectoryVotes: z.boolean().default(true),
|
|
50
39
|
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
|
|
51
40
|
preferredAirPlayDevice: z.string().min(1).optional(),
|
|
41
|
+
linuxAirPlayEnabled: z.boolean().optional(),
|
|
52
42
|
tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
|
|
53
43
|
skipBrokenStreams: z.boolean().default(true),
|
|
54
44
|
mediaKeys: z
|
|
@@ -64,6 +54,7 @@ const settingsSchema = z.object({
|
|
|
64
54
|
reduceMotion: z.boolean().default(false),
|
|
65
55
|
mouseSupport: z.boolean().default(true),
|
|
66
56
|
automaticUpdateChecks: z.boolean().default(true),
|
|
57
|
+
desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'),
|
|
67
58
|
agentControl: z.object({
|
|
68
59
|
enabled: z.boolean().default(false),
|
|
69
60
|
openUiOnPlay: z.boolean().default(true),
|
|
@@ -167,6 +158,10 @@ const librarySchema = z.object({
|
|
|
167
158
|
}))
|
|
168
159
|
.default([]),
|
|
169
160
|
favorites: z.array(stationSchema).default([]),
|
|
161
|
+
presets: z.array(z.object({
|
|
162
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/),
|
|
163
|
+
station: stationSchema
|
|
164
|
+
})).max(200).default([]),
|
|
170
165
|
imported: z.array(stationSchema).default([]),
|
|
171
166
|
trackHistory: z
|
|
172
167
|
.array(z.object({
|
|
@@ -235,16 +230,70 @@ export class JsonLibraryStore {
|
|
|
235
230
|
state;
|
|
236
231
|
idGenerator;
|
|
237
232
|
now;
|
|
233
|
+
revision = '';
|
|
238
234
|
constructor(filePath = defaultLibraryPath(), options = {}) {
|
|
239
235
|
this.filePath = filePath;
|
|
240
236
|
this.idGenerator = options.idGenerator ?? randomUUID;
|
|
241
237
|
this.now = options.now ?? (() => new Date());
|
|
238
|
+
this.revision = this.diskRevision();
|
|
242
239
|
this.state = this.read();
|
|
240
|
+
// Normalize libraries saved by an earlier preview before any legacy
|
|
241
|
+
// background reader encounters its new enum value again.
|
|
242
|
+
if (!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferencePath()))
|
|
243
|
+
this.commit(this.state);
|
|
243
244
|
}
|
|
244
245
|
snapshot() {
|
|
246
|
+
this.refresh();
|
|
247
|
+
return this.cachedSnapshot();
|
|
248
|
+
}
|
|
249
|
+
cachedSnapshot() {
|
|
245
250
|
return structuredClone(this.state);
|
|
246
251
|
}
|
|
252
|
+
/** Refresh atomically replaced files without recovering a live listening session. */
|
|
253
|
+
refresh() {
|
|
254
|
+
const revision = this.diskRevision();
|
|
255
|
+
if (revision === this.revision)
|
|
256
|
+
return false;
|
|
257
|
+
const next = this.readForCurrentSession();
|
|
258
|
+
this.state = next;
|
|
259
|
+
this.revision = revision;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
settings() {
|
|
263
|
+
this.refresh();
|
|
264
|
+
return structuredClone(this.state.settings);
|
|
265
|
+
}
|
|
266
|
+
diskRevision() {
|
|
267
|
+
return [this.filePath, this.receiverPreferencePath()].map(file => {
|
|
268
|
+
try {
|
|
269
|
+
const info = statSync(file, { bigint: true });
|
|
270
|
+
return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error.code === 'ENOENT')
|
|
274
|
+
return 'missing';
|
|
275
|
+
throw storageError('read the RadioCLI library', error);
|
|
276
|
+
}
|
|
277
|
+
}).join('|');
|
|
278
|
+
}
|
|
279
|
+
savePreset(name, station) {
|
|
280
|
+
this.refresh();
|
|
281
|
+
const key = normalizePresetName(name);
|
|
282
|
+
const presets = this.state.presets ?? [];
|
|
283
|
+
if (presets.length >= 200 && !presets.some(preset => preset.name === key))
|
|
284
|
+
throw new Error('RadioCLI supports at most 200 station presets. Remove one with radiocli preset remove <name>.');
|
|
285
|
+
this.commit({ ...this.state, presets: [{ name: key, station }, ...presets.filter(preset => preset.name !== key)] });
|
|
286
|
+
}
|
|
287
|
+
removePreset(name) {
|
|
288
|
+
this.refresh();
|
|
289
|
+
const key = normalizePresetName(name);
|
|
290
|
+
const presets = this.state.presets ?? [];
|
|
291
|
+
if (!presets.some(preset => preset.name === key))
|
|
292
|
+
throw new Error(`No preset named ${key}. Run radiocli presets to see saved names.`);
|
|
293
|
+
this.commit({ ...this.state, presets: presets.filter(preset => preset.name !== key) });
|
|
294
|
+
}
|
|
247
295
|
exportBackup(requestedPath) {
|
|
296
|
+
this.refresh();
|
|
248
297
|
const targetPath = requestedPath?.trim()
|
|
249
298
|
? resolveUserPath(requestedPath)
|
|
250
299
|
: resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
|
|
@@ -284,7 +333,9 @@ export class JsonLibraryStore {
|
|
|
284
333
|
? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
|
|
285
334
|
: undefined;
|
|
286
335
|
if (safetyBackupPath) {
|
|
287
|
-
|
|
336
|
+
// Another TUI or background player may have saved since this owner
|
|
337
|
+
// last refreshed. Preserve its latest data while holding the lock.
|
|
338
|
+
writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.read()));
|
|
288
339
|
}
|
|
289
340
|
this.write(importedState);
|
|
290
341
|
this.state = importedState;
|
|
@@ -295,6 +346,7 @@ export class JsonLibraryStore {
|
|
|
295
346
|
}
|
|
296
347
|
}
|
|
297
348
|
updateSettings(settings) {
|
|
349
|
+
this.refresh();
|
|
298
350
|
return this.commit({
|
|
299
351
|
...this.state,
|
|
300
352
|
settings: {
|
|
@@ -305,12 +357,13 @@ export class JsonLibraryStore {
|
|
|
305
357
|
});
|
|
306
358
|
}
|
|
307
359
|
updateCheckState(updateCheck) {
|
|
360
|
+
this.refresh();
|
|
308
361
|
return this.commit({ ...this.state, updateCheck });
|
|
309
362
|
}
|
|
310
363
|
addAlarm(input) {
|
|
311
364
|
const release = acquireStoreLock(this.filePath);
|
|
312
365
|
try {
|
|
313
|
-
const latestState = this.
|
|
366
|
+
const latestState = this.readForCurrentSession();
|
|
314
367
|
if (latestState.alarms.length >= MAX_ALARMS) {
|
|
315
368
|
throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
|
|
316
369
|
}
|
|
@@ -352,7 +405,7 @@ export class JsonLibraryStore {
|
|
|
352
405
|
removeAlarm(id) {
|
|
353
406
|
const release = acquireStoreLock(this.filePath);
|
|
354
407
|
try {
|
|
355
|
-
const latestState = this.
|
|
408
|
+
const latestState = this.readForCurrentSession();
|
|
356
409
|
if (!latestState.alarms.some(alarm => alarm.id === id)) {
|
|
357
410
|
this.state = latestState;
|
|
358
411
|
return false;
|
|
@@ -374,10 +427,12 @@ export class JsonLibraryStore {
|
|
|
374
427
|
}));
|
|
375
428
|
}
|
|
376
429
|
getAlarm(id) {
|
|
430
|
+
this.refresh();
|
|
377
431
|
const alarm = this.state.alarms.find(item => item.id === id);
|
|
378
432
|
return alarm ? structuredClone(alarm) : undefined;
|
|
379
433
|
}
|
|
380
434
|
listAlarms() {
|
|
435
|
+
this.refresh();
|
|
381
436
|
return structuredClone(this.state.alarms);
|
|
382
437
|
}
|
|
383
438
|
recordAlarmOutcome(id, outcome, options = {}) {
|
|
@@ -409,6 +464,7 @@ export class JsonLibraryStore {
|
|
|
409
464
|
}));
|
|
410
465
|
}
|
|
411
466
|
addRecent(station) {
|
|
467
|
+
this.refresh();
|
|
412
468
|
const key = stationKey(station);
|
|
413
469
|
const recent = [
|
|
414
470
|
{ station, playedAt: new Date().toISOString() },
|
|
@@ -419,9 +475,10 @@ export class JsonLibraryStore {
|
|
|
419
475
|
// Persist the ICY track titles a station announces so "what was that song?"
|
|
420
476
|
// is answerable later. Consecutive duplicates on the same station are skipped.
|
|
421
477
|
recordTrack(station, title) {
|
|
478
|
+
this.refresh();
|
|
422
479
|
const cleaned = title.trim();
|
|
423
480
|
if (!cleaned) {
|
|
424
|
-
return this.
|
|
481
|
+
return this.cachedSnapshot();
|
|
425
482
|
}
|
|
426
483
|
const key = stationKey(station);
|
|
427
484
|
const last = this.state.trackHistory[0];
|
|
@@ -438,6 +495,7 @@ export class JsonLibraryStore {
|
|
|
438
495
|
}
|
|
439
496
|
// Most-recent-first search queries for up-arrow recall in the search box.
|
|
440
497
|
addSearch(query) {
|
|
498
|
+
this.refresh();
|
|
441
499
|
const cleaned = query.trim();
|
|
442
500
|
if (!cleaned) {
|
|
443
501
|
return this.snapshot();
|
|
@@ -446,6 +504,7 @@ export class JsonLibraryStore {
|
|
|
446
504
|
return this.commit({ ...this.state, searchHistory });
|
|
447
505
|
}
|
|
448
506
|
startListeningSession(station, startedAt = new Date()) {
|
|
507
|
+
this.refresh();
|
|
449
508
|
const finishedState = recoverActiveListeningSessionInState(this.state);
|
|
450
509
|
const session = {
|
|
451
510
|
id: `${startedAt.toISOString()}-${stationKey(station)}`,
|
|
@@ -462,6 +521,7 @@ export class JsonLibraryStore {
|
|
|
462
521
|
});
|
|
463
522
|
}
|
|
464
523
|
checkpointActiveListeningSession(activeAt = new Date()) {
|
|
524
|
+
this.refresh();
|
|
465
525
|
const active = this.state.activity.sessions[0];
|
|
466
526
|
if (!active || active.endedAt) {
|
|
467
527
|
return this.snapshot();
|
|
@@ -482,6 +542,7 @@ export class JsonLibraryStore {
|
|
|
482
542
|
});
|
|
483
543
|
}
|
|
484
544
|
finishActiveListeningSession(endedAt = new Date()) {
|
|
545
|
+
this.refresh();
|
|
485
546
|
const nextState = finishActiveListeningSessionInState(this.state, endedAt);
|
|
486
547
|
if (nextState === this.state) {
|
|
487
548
|
return this.snapshot();
|
|
@@ -489,6 +550,7 @@ export class JsonLibraryStore {
|
|
|
489
550
|
return this.commit(nextState);
|
|
490
551
|
}
|
|
491
552
|
toggleFavorite(station) {
|
|
553
|
+
this.refresh();
|
|
492
554
|
const key = stationKey(station);
|
|
493
555
|
const exists = this.state.favorites.some(item => stationKey(item) === key);
|
|
494
556
|
const favorites = exists
|
|
@@ -497,6 +559,7 @@ export class JsonLibraryStore {
|
|
|
497
559
|
return this.commit({ ...this.state, favorites });
|
|
498
560
|
}
|
|
499
561
|
addImported(stations) {
|
|
562
|
+
this.refresh();
|
|
500
563
|
const incoming = [];
|
|
501
564
|
const incomingKeys = new Set();
|
|
502
565
|
for (const station of stations) {
|
|
@@ -518,13 +581,14 @@ export class JsonLibraryStore {
|
|
|
518
581
|
if (!station) {
|
|
519
582
|
return false;
|
|
520
583
|
}
|
|
584
|
+
this.refresh();
|
|
521
585
|
const key = stationKey(station);
|
|
522
586
|
return this.state.favorites.some(item => stationKey(item) === key);
|
|
523
587
|
}
|
|
524
588
|
mutateAlarm(id, change) {
|
|
525
589
|
const release = acquireStoreLock(this.filePath);
|
|
526
590
|
try {
|
|
527
|
-
const latestState = this.
|
|
591
|
+
const latestState = this.readForCurrentSession();
|
|
528
592
|
const existing = latestState.alarms.find(alarm => alarm.id === id);
|
|
529
593
|
if (!existing) {
|
|
530
594
|
this.state = latestState;
|
|
@@ -553,10 +617,17 @@ export class JsonLibraryStore {
|
|
|
553
617
|
return defaultState();
|
|
554
618
|
throw storageError('read the RadioCLI library', error);
|
|
555
619
|
}
|
|
620
|
+
let state;
|
|
556
621
|
try {
|
|
557
|
-
|
|
622
|
+
state = migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
|
|
558
623
|
}
|
|
559
|
-
catch {
|
|
624
|
+
catch (error) {
|
|
625
|
+
if (error instanceof z.ZodError && error.issues.some(issue => issue.code === 'invalid_value' && issue.path.length === 2 &&
|
|
626
|
+
issue.path[0] === 'settings' && issue.path[1] === 'receiverStyle')) {
|
|
627
|
+
// Another running build may have saved a newer receiver. Its library
|
|
628
|
+
// is intact: never quarantine it or replace it with empty defaults.
|
|
629
|
+
throw new Error('The RadioCLI library uses a receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated styles.');
|
|
630
|
+
}
|
|
560
631
|
try {
|
|
561
632
|
assertStorageWritable(this.filePath);
|
|
562
633
|
renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
|
|
@@ -567,15 +638,42 @@ export class JsonLibraryStore {
|
|
|
567
638
|
}
|
|
568
639
|
return defaultState();
|
|
569
640
|
}
|
|
641
|
+
// A legacy writer can still change ordinary preferences and favorites. Its
|
|
642
|
+
// fallback style must not erase the selection saved by an updated process.
|
|
643
|
+
let preference;
|
|
644
|
+
try {
|
|
645
|
+
preference = readFileSync(this.receiverPreferencePath(), 'utf8');
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
if (error.code === 'ENOENT')
|
|
649
|
+
return state;
|
|
650
|
+
throw storageError('read the receiver preference', error);
|
|
651
|
+
}
|
|
652
|
+
const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
|
|
653
|
+
if (!parsed.success)
|
|
654
|
+
throw new Error('The RadioCLI receiver preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
|
|
655
|
+
if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
|
|
656
|
+
state.settings.receiverStyle = parsed.data.receiverStyle;
|
|
657
|
+
return state;
|
|
658
|
+
}
|
|
659
|
+
receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
|
|
660
|
+
readForCurrentSession() {
|
|
661
|
+
const next = this.read();
|
|
662
|
+
// Disk intentionally stores auto instead of reopening a receiver on launch.
|
|
663
|
+
// Within this owner, unrelated durable changes retain its selected output.
|
|
664
|
+
if (this.state.settings.preferredBackend === 'airplay' && next.settings.preferredBackend === 'auto') {
|
|
665
|
+
next.settings.preferredBackend = 'airplay';
|
|
666
|
+
}
|
|
667
|
+
return next;
|
|
570
668
|
}
|
|
571
669
|
commit(nextState) {
|
|
572
670
|
const release = acquireStoreLock(this.filePath);
|
|
573
671
|
try {
|
|
574
|
-
const latestState = this.
|
|
672
|
+
const latestState = this.readForCurrentSession();
|
|
575
673
|
const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
|
|
576
674
|
this.write(mergedState);
|
|
577
675
|
this.state = mergedState;
|
|
578
|
-
return this.
|
|
676
|
+
return this.cachedSnapshot();
|
|
579
677
|
}
|
|
580
678
|
finally {
|
|
581
679
|
release();
|
|
@@ -585,7 +683,47 @@ export class JsonLibraryStore {
|
|
|
585
683
|
try {
|
|
586
684
|
assertStorageWritable(this.filePath);
|
|
587
685
|
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
588
|
-
|
|
686
|
+
const diskState = libraryStateForDisk(state);
|
|
687
|
+
const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle);
|
|
688
|
+
const preferencePath = this.receiverPreferencePath();
|
|
689
|
+
let previousPreference;
|
|
690
|
+
try {
|
|
691
|
+
previousPreference = readFileSync(preferencePath, 'utf8');
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (error.code !== 'ENOENT')
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
const nextPreference = separateReceiver
|
|
698
|
+
? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
|
|
699
|
+
: undefined;
|
|
700
|
+
const preferenceChanged = previousPreference !== nextPreference;
|
|
701
|
+
if (preferenceChanged) {
|
|
702
|
+
if (nextPreference === undefined)
|
|
703
|
+
rmSync(preferencePath);
|
|
704
|
+
else
|
|
705
|
+
writeTextAtomically(preferencePath, nextPreference);
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
writeJsonAtomically(this.filePath, separateReceiver
|
|
709
|
+
? { ...diskState, settings: { ...diskState.settings, receiverStyle: 'ultracode' } }
|
|
710
|
+
: diskState);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
if (preferenceChanged) {
|
|
714
|
+
if (previousPreference === undefined)
|
|
715
|
+
rmSync(preferencePath, { force: true });
|
|
716
|
+
else
|
|
717
|
+
writeTextAtomically(preferencePath, previousPreference);
|
|
718
|
+
}
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
this.revision = this.diskRevision();
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
this.revision = '';
|
|
726
|
+
}
|
|
589
727
|
}
|
|
590
728
|
catch (error) {
|
|
591
729
|
throw storageError('write the RadioCLI library', error);
|
|
@@ -593,7 +731,7 @@ export class JsonLibraryStore {
|
|
|
593
731
|
}
|
|
594
732
|
}
|
|
595
733
|
export function stationKey(station) {
|
|
596
|
-
return `${station.provider}:${station.id}`;
|
|
734
|
+
return `${station.provider}:${migrateStationIdentity(station).id}`;
|
|
597
735
|
}
|
|
598
736
|
function finishActiveListeningSessionInState(state, endedAt) {
|
|
599
737
|
const active = state.activity.sessions[0];
|
|
@@ -630,8 +768,8 @@ function recoverActiveListeningSessionInState(state) {
|
|
|
630
768
|
: Date.now();
|
|
631
769
|
return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
|
|
632
770
|
}
|
|
633
|
-
export function defaultLibraryPath() {
|
|
634
|
-
const { library, legacyLibrary } = platformPaths();
|
|
771
|
+
export function defaultLibraryPath(options = {}) {
|
|
772
|
+
const { library, legacyLibrary } = platformPaths(options);
|
|
635
773
|
for (const filePath of [library, legacyLibrary]) {
|
|
636
774
|
try {
|
|
637
775
|
statSync(filePath);
|
|
@@ -662,6 +800,7 @@ function defaultState() {
|
|
|
662
800
|
return {
|
|
663
801
|
recent: [],
|
|
664
802
|
favorites: [],
|
|
803
|
+
presets: [],
|
|
665
804
|
imported: [],
|
|
666
805
|
trackHistory: [],
|
|
667
806
|
searchHistory: [],
|
|
@@ -691,9 +830,32 @@ function defaultState() {
|
|
|
691
830
|
};
|
|
692
831
|
}
|
|
693
832
|
function migrateLibraryState(state) {
|
|
833
|
+
const aliases = new Map();
|
|
834
|
+
const migrate = (station) => {
|
|
835
|
+
const next = migrateStationIdentity(station);
|
|
836
|
+
if (next.id !== station.id) {
|
|
837
|
+
const key = `${station.provider}:${station.id}`;
|
|
838
|
+
const targets = aliases.get(key) ?? new Set();
|
|
839
|
+
targets.add(stationKey(next));
|
|
840
|
+
aliases.set(key, targets);
|
|
841
|
+
}
|
|
842
|
+
return next;
|
|
843
|
+
};
|
|
844
|
+
const recent = state.recent.map(item => ({ ...item, station: migrate(item.station) }));
|
|
845
|
+
const favorites = state.favorites.map(migrate);
|
|
846
|
+
const imported = state.imported.map(migrate);
|
|
847
|
+
const presets = state.presets?.map(item => ({ ...item, station: migrate(item.station) }));
|
|
848
|
+
const sessions = state.activity.sessions.map(item => ({ ...item, station: migrate(item.station) }));
|
|
849
|
+
const alarms = state.alarms.map(alarm => normalizeAlarm({ ...alarm, station: migrate(alarm.station),
|
|
850
|
+
playback: { ...alarm.playback, ...(alarm.playback.fallbackStation ? { fallbackStation: migrate(alarm.playback.fallbackStation) } : {}) } }));
|
|
694
851
|
return {
|
|
695
852
|
...state,
|
|
696
|
-
|
|
853
|
+
recent, favorites, imported, presets, alarms,
|
|
854
|
+
activity: { sessions },
|
|
855
|
+
trackHistory: state.trackHistory.map(item => {
|
|
856
|
+
const targets = aliases.get(item.stationKey);
|
|
857
|
+
return targets?.size === 1 ? { ...item, stationKey: [...targets][0] } : item;
|
|
858
|
+
}),
|
|
697
859
|
settings: {
|
|
698
860
|
...state.settings,
|
|
699
861
|
preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
|
|
@@ -718,14 +880,27 @@ function libraryStateForDisk(state) {
|
|
|
718
880
|
: state;
|
|
719
881
|
}
|
|
720
882
|
function writeJsonAtomically(filePath, value) {
|
|
883
|
+
writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
884
|
+
}
|
|
885
|
+
function writeTextAtomically(filePath, text) {
|
|
721
886
|
const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
722
887
|
try {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
888
|
+
const fd = openSync(tempPath, 'wx', 0o600);
|
|
889
|
+
try {
|
|
890
|
+
writeFileSync(fd, text, 'utf8');
|
|
891
|
+
if (process.platform !== 'win32')
|
|
892
|
+
chmodSync(tempPath, 0o600);
|
|
893
|
+
fsyncSync(fd);
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
try {
|
|
897
|
+
closeSync(fd);
|
|
898
|
+
}
|
|
899
|
+
catch { /* Preserve the original persistence error. */ }
|
|
900
|
+
throw error;
|
|
726
901
|
}
|
|
727
|
-
|
|
728
|
-
|
|
902
|
+
closeSync(fd);
|
|
903
|
+
replaceFileSync(tempPath, filePath);
|
|
729
904
|
}
|
|
730
905
|
catch (error) {
|
|
731
906
|
try {
|
|
@@ -734,6 +909,23 @@ function writeJsonAtomically(filePath, value) {
|
|
|
734
909
|
catch { /* Preserve the original write failure. */ }
|
|
735
910
|
throw storageError('write RadioCLI data', error);
|
|
736
911
|
}
|
|
912
|
+
// Flush the replacement directory where supported. Once rename succeeds the
|
|
913
|
+
// transaction is committed: a directory-sync failure cannot roll it back or
|
|
914
|
+
// leave the caller's in-memory state describing the previous file. Windows
|
|
915
|
+
// and some network/removable filesystems do not support syncing directories.
|
|
916
|
+
let directoryFd;
|
|
917
|
+
try {
|
|
918
|
+
directoryFd = openSync(dirname(filePath), 'r');
|
|
919
|
+
fsyncSync(directoryFd);
|
|
920
|
+
}
|
|
921
|
+
catch { /* The file itself was flushed before replacement. */ }
|
|
922
|
+
finally {
|
|
923
|
+
if (directoryFd !== undefined)
|
|
924
|
+
try {
|
|
925
|
+
closeSync(directoryFd);
|
|
926
|
+
}
|
|
927
|
+
catch { /* Already committed. */ }
|
|
928
|
+
}
|
|
737
929
|
}
|
|
738
930
|
function acquireStoreLock(filePath) {
|
|
739
931
|
const lockPath = `${filePath}.lock`;
|
|
@@ -744,37 +936,11 @@ function acquireStoreLock(filePath) {
|
|
|
744
936
|
catch (error) {
|
|
745
937
|
throw storageError('write the RadioCLI library', error);
|
|
746
938
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
// The write has already succeeded or failed. Lock cleanup cannot reverse that result.
|
|
753
|
-
try {
|
|
754
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
755
|
-
}
|
|
756
|
-
catch { /* Stale locks expire below. */ }
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
catch (error) {
|
|
760
|
-
const code = error.code;
|
|
761
|
-
if (code !== 'EEXIST')
|
|
762
|
-
throw storageError('lock the RadioCLI library', error);
|
|
763
|
-
try {
|
|
764
|
-
if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
|
|
765
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
catch (lockError) {
|
|
769
|
-
if (lockError.code !== 'ENOENT') {
|
|
770
|
-
throw storageError('lock the RadioCLI library', lockError);
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
if (Date.now() >= deadline) {
|
|
774
|
-
throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), { code: 'EBUSY', cause: error }));
|
|
775
|
-
}
|
|
776
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
777
|
-
}
|
|
939
|
+
try {
|
|
940
|
+
return acquireFileLock(lockPath);
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
throw storageError('lock the RadioCLI library', error);
|
|
778
944
|
}
|
|
779
945
|
}
|
|
780
946
|
class LibraryStorageError extends Error {
|
|
@@ -794,10 +960,17 @@ class LibraryStorageError extends Error {
|
|
|
794
960
|
function storageError(operation, cause) {
|
|
795
961
|
return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
|
|
796
962
|
}
|
|
963
|
+
function normalizePresetName(name) {
|
|
964
|
+
const key = name.trim().toLowerCase();
|
|
965
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(key))
|
|
966
|
+
throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.');
|
|
967
|
+
return key;
|
|
968
|
+
}
|
|
797
969
|
function mergeLibraryChanges(base, disk, next) {
|
|
798
970
|
return {
|
|
799
971
|
recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
|
|
800
972
|
favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
|
|
973
|
+
presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true),
|
|
801
974
|
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
|
|
802
975
|
trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
|
|
803
976
|
searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
|
|
@@ -5,9 +5,9 @@ import { computeListeningStats } from '../activity/stats.js';
|
|
|
5
5
|
import { playbackBackendLabel } from '../player/backend-install.js';
|
|
6
6
|
import { visibleWindow } from './list-window.js';
|
|
7
7
|
import { displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate } from './format.js';
|
|
8
|
-
import { homeItems, settingsGroup,
|
|
8
|
+
import { homeItems, settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage } from './screen-items.js';
|
|
9
9
|
import { screenTitle } from './screen-meta.js';
|
|
10
|
-
import { keyHelpSections,
|
|
10
|
+
import { keyHelpSections, commandHelpForAirPlay } from './help-content.js';
|
|
11
11
|
import { settingLabel, settingValue } from './screens/SettingsScreen.js';
|
|
12
12
|
import { exploreMapLand, mapMarker, panelBorder, textDim, textMuted, themeAccent } from './theme.js';
|
|
13
13
|
import { panelBorderStyle, useDisplay } from './display-context.js';
|
|
@@ -55,9 +55,11 @@ function AdaptiveContentBody(props) {
|
|
|
55
55
|
width,
|
|
56
56
|
mode,
|
|
57
57
|
ascii,
|
|
58
|
-
airPlaySupported
|
|
58
|
+
airPlaySupported,
|
|
59
|
+
platform: props.platform
|
|
59
60
|
});
|
|
60
61
|
const empty = adaptiveEmptyState({
|
|
62
|
+
airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
|
|
61
63
|
screen,
|
|
62
64
|
searchQuery,
|
|
63
65
|
editingSearch,
|
|
@@ -105,9 +107,11 @@ function AdaptiveContentBody(props) {
|
|
|
105
107
|
width,
|
|
106
108
|
mode,
|
|
107
109
|
ascii,
|
|
108
|
-
airPlaySupported
|
|
110
|
+
airPlaySupported,
|
|
111
|
+
platform: props.platform
|
|
109
112
|
});
|
|
110
113
|
const empty = adaptiveEmptyState({
|
|
114
|
+
airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
|
|
111
115
|
screen,
|
|
112
116
|
searchQuery,
|
|
113
117
|
editingSearch,
|
|
@@ -273,15 +277,15 @@ function adaptiveRows(input) {
|
|
|
273
277
|
}));
|
|
274
278
|
}
|
|
275
279
|
if (screen === 'settings') {
|
|
276
|
-
const pageItems = settingsItemsForPage(input.settingsPage);
|
|
280
|
+
const pageItems = settingsItemsForPage(input.settingsPage, input.airPlaySupported);
|
|
277
281
|
const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
|
|
278
282
|
const labelWidth = pairedColumnWidth(labels, width, mode);
|
|
279
283
|
return pageItems.map((item, index) => ({
|
|
280
284
|
key: item,
|
|
281
285
|
label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
|
|
282
286
|
detail: input.settingsPage === 'root'
|
|
283
|
-
? adaptiveSettingsRootValue(item)
|
|
284
|
-
: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
|
|
287
|
+
? adaptiveSettingsRootValue(item, input.airPlaySupported)
|
|
288
|
+
: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion, input.platform),
|
|
285
289
|
separator: ' ',
|
|
286
290
|
index
|
|
287
291
|
}));
|
|
@@ -312,7 +316,7 @@ function adaptiveRows(input) {
|
|
|
312
316
|
}
|
|
313
317
|
}
|
|
314
318
|
rows.push({ key: 'section-commands', label: 'Commands', heading: true });
|
|
315
|
-
for (const command of
|
|
319
|
+
for (const command of commandHelpForAirPlay(input.airPlaySupported)) {
|
|
316
320
|
rows.push({ key: `command-${command.name}`, label: `:${command.name}${command.args ? ` ${command.args}` : ''}`, detail: command.description, index });
|
|
317
321
|
index += 1;
|
|
318
322
|
}
|
|
@@ -320,8 +324,8 @@ function adaptiveRows(input) {
|
|
|
320
324
|
}
|
|
321
325
|
return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii);
|
|
322
326
|
}
|
|
323
|
-
function adaptiveSettingsRootValue(item) {
|
|
324
|
-
const group =
|
|
327
|
+
function adaptiveSettingsRootValue(item, airPlayVisible) {
|
|
328
|
+
const group = settingsGroupsForAirPlay(airPlayVisible).find(candidate => candidate.label === item);
|
|
325
329
|
return group ? `${group.items.length} settings ›` : undefined;
|
|
326
330
|
}
|
|
327
331
|
function stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii) {
|
|
@@ -378,6 +382,8 @@ function adaptiveEmptyState(input) {
|
|
|
378
382
|
return [{ key: 'error', label: stationError }, { key: 'hint', label: 'Try again later or reopen this view.' }];
|
|
379
383
|
}
|
|
380
384
|
if (screen === 'airplay-settings') {
|
|
385
|
+
if (input.airPlaySetupRequired)
|
|
386
|
+
return [{ key: 'empty', label: 'Setup required' }, { key: 'hint', label: 'radiocli setup' }, { key: 'option', label: '--airplay' }];
|
|
381
387
|
return [{ key: 'empty', label: 'No AirPlay receivers found.' }, { key: 'hint', label: 'Press r to scan again.' }];
|
|
382
388
|
}
|
|
383
389
|
return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
|
|
@@ -415,6 +421,8 @@ function adaptiveStatus(props) {
|
|
|
415
421
|
return playingStation ? playingStation.name : 'Receiver authentication';
|
|
416
422
|
}
|
|
417
423
|
if (screen === 'airplay-settings') {
|
|
424
|
+
if (props.platform === 'linux' && !props.backends.includes('airplay'))
|
|
425
|
+
return 'Setup required';
|
|
418
426
|
return `${props.airPlayDevices.length} receivers · ${playbackBackendLabel(playback.backend)}`;
|
|
419
427
|
}
|
|
420
428
|
return `${stations.length.toLocaleString()} stations${playingStation ? ` · playing ${playingStation.name}` : ''}`;
|