@ciphore/radiocli 0.2.4 → 0.2.6
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 +103 -2
- package/CONTRIBUTING.md +28 -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 +182 -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 +130 -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 +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- 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/components/ReceiverVisualRow.js +39 -0
- 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/NowPlayingScreen.js +2 -21
- 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/theme.js +46 -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/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- 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,46 @@ 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
|
+
// Freeze the 0.2.5 enum independently of UI ordering. New styles must stay out
|
|
22
|
+
// of both its shared library and its strictly validated .receiver.json sidecar.
|
|
23
|
+
// The new namespace is ignored by old playback/MCP readers and writers.
|
|
24
|
+
const sharedLibraryReceiverStyles = new Set([
|
|
25
|
+
'ultracode', 'pulse-grid', 'hex-pulse', 'moire', 'galaxy', 'cyclone', 'nebula', 'lava-lamp',
|
|
26
|
+
'gallop-fine', 'motion-contour', 'ribbon', 'braille-wave', 'silk', 'lissajous', 'harmonograph',
|
|
27
|
+
'spirograph', 'goniometer', 'radial-eq', 'spectrogram', 'caustics', 'ripple-tank', 'leds',
|
|
28
|
+
'matrix', 'cascade', 'ordered-dither', 'xor-texture', 'pixel-crush', 'copper-bars',
|
|
29
|
+
'rotozoomer', 'mesh', 'hologram', 'equalizer', 'tunnel', 'kaleidoscope', 'phosphene',
|
|
30
|
+
'phyllotaxis', 'chladni', 'newton', 'cube', 'spinning-donut', 'tesseract', 'torus-knot',
|
|
31
|
+
'twister', 'lorenz', 'fern', 'fractal-tree', 'julia', 'neural-net', 'constellation',
|
|
32
|
+
'starfield', 'fireworks', 'fire', 'aurora', 'stormfront', 'planetarium', 'sumi-ocean',
|
|
33
|
+
'liftoff', 'flyover', 'skyline', 'golden-gate', 'manhattan', 'alpine', 'crossfade'
|
|
34
|
+
]);
|
|
35
|
+
const receiverPreferenceSchema = z.object({
|
|
36
|
+
version: z.literal(1),
|
|
37
|
+
receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)),
|
|
38
|
+
libraryReceiverStyle: z.literal('ultracode')
|
|
39
|
+
});
|
|
40
|
+
// Older playback/MCP processes validate this original enum on every refresh.
|
|
41
|
+
// New colors must stay out of their shared JSON, even after unrelated saves.
|
|
42
|
+
const sharedLibraryThemes = new Set([
|
|
43
|
+
'green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper',
|
|
44
|
+
'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'
|
|
45
|
+
]);
|
|
46
|
+
const themePreferenceSchema = z.object({
|
|
47
|
+
version: z.literal(1),
|
|
48
|
+
theme: z.enum(themeNames),
|
|
49
|
+
libraryTheme: z.literal('green')
|
|
50
|
+
});
|
|
42
51
|
const settingsSchema = z.object({
|
|
43
52
|
theme: z.enum(themeNames).default('green'),
|
|
44
53
|
receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
|
|
@@ -49,6 +58,7 @@ const settingsSchema = z.object({
|
|
|
49
58
|
shareDirectoryVotes: z.boolean().default(true),
|
|
50
59
|
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
|
|
51
60
|
preferredAirPlayDevice: z.string().min(1).optional(),
|
|
61
|
+
linuxAirPlayEnabled: z.boolean().optional(),
|
|
52
62
|
tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
|
|
53
63
|
skipBrokenStreams: z.boolean().default(true),
|
|
54
64
|
mediaKeys: z
|
|
@@ -64,6 +74,7 @@ const settingsSchema = z.object({
|
|
|
64
74
|
reduceMotion: z.boolean().default(false),
|
|
65
75
|
mouseSupport: z.boolean().default(true),
|
|
66
76
|
automaticUpdateChecks: z.boolean().default(true),
|
|
77
|
+
desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'),
|
|
67
78
|
agentControl: z.object({
|
|
68
79
|
enabled: z.boolean().default(false),
|
|
69
80
|
openUiOnPlay: z.boolean().default(true),
|
|
@@ -167,6 +178,10 @@ const librarySchema = z.object({
|
|
|
167
178
|
}))
|
|
168
179
|
.default([]),
|
|
169
180
|
favorites: z.array(stationSchema).default([]),
|
|
181
|
+
presets: z.array(z.object({
|
|
182
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/),
|
|
183
|
+
station: stationSchema
|
|
184
|
+
})).max(200).default([]),
|
|
170
185
|
imported: z.array(stationSchema).default([]),
|
|
171
186
|
trackHistory: z
|
|
172
187
|
.array(z.object({
|
|
@@ -235,16 +250,71 @@ export class JsonLibraryStore {
|
|
|
235
250
|
state;
|
|
236
251
|
idGenerator;
|
|
237
252
|
now;
|
|
253
|
+
revision = '';
|
|
238
254
|
constructor(filePath = defaultLibraryPath(), options = {}) {
|
|
239
255
|
this.filePath = filePath;
|
|
240
256
|
this.idGenerator = options.idGenerator ?? randomUUID;
|
|
241
257
|
this.now = options.now ?? (() => new Date());
|
|
258
|
+
this.revision = this.diskRevision();
|
|
242
259
|
this.state = this.read();
|
|
260
|
+
// Normalize libraries saved by an earlier preview before any legacy
|
|
261
|
+
// background reader encounters its new enum value again.
|
|
262
|
+
if ((!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferenceV2Path())) ||
|
|
263
|
+
(!sharedLibraryThemes.has(this.state.settings.theme) && !existsSync(this.themePreferencePath())))
|
|
264
|
+
this.commit(this.state);
|
|
243
265
|
}
|
|
244
266
|
snapshot() {
|
|
267
|
+
this.refresh();
|
|
268
|
+
return this.cachedSnapshot();
|
|
269
|
+
}
|
|
270
|
+
cachedSnapshot() {
|
|
245
271
|
return structuredClone(this.state);
|
|
246
272
|
}
|
|
273
|
+
/** Refresh atomically replaced files without recovering a live listening session. */
|
|
274
|
+
refresh() {
|
|
275
|
+
const revision = this.diskRevision();
|
|
276
|
+
if (revision === this.revision)
|
|
277
|
+
return false;
|
|
278
|
+
const next = this.readForCurrentSession();
|
|
279
|
+
this.state = next;
|
|
280
|
+
this.revision = revision;
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
settings() {
|
|
284
|
+
this.refresh();
|
|
285
|
+
return structuredClone(this.state.settings);
|
|
286
|
+
}
|
|
287
|
+
diskRevision() {
|
|
288
|
+
return [this.filePath, this.receiverPreferencePath(), this.receiverPreferenceV2Path(), this.themePreferencePath()].map(file => {
|
|
289
|
+
try {
|
|
290
|
+
const info = statSync(file, { bigint: true });
|
|
291
|
+
return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error.code === 'ENOENT')
|
|
295
|
+
return 'missing';
|
|
296
|
+
throw storageError('read the RadioCLI library', error);
|
|
297
|
+
}
|
|
298
|
+
}).join('|');
|
|
299
|
+
}
|
|
300
|
+
savePreset(name, station) {
|
|
301
|
+
this.refresh();
|
|
302
|
+
const key = normalizePresetName(name);
|
|
303
|
+
const presets = this.state.presets ?? [];
|
|
304
|
+
if (presets.length >= 200 && !presets.some(preset => preset.name === key))
|
|
305
|
+
throw new Error('RadioCLI supports at most 200 station presets. Remove one with radiocli preset remove <name>.');
|
|
306
|
+
this.commit({ ...this.state, presets: [{ name: key, station }, ...presets.filter(preset => preset.name !== key)] });
|
|
307
|
+
}
|
|
308
|
+
removePreset(name) {
|
|
309
|
+
this.refresh();
|
|
310
|
+
const key = normalizePresetName(name);
|
|
311
|
+
const presets = this.state.presets ?? [];
|
|
312
|
+
if (!presets.some(preset => preset.name === key))
|
|
313
|
+
throw new Error(`No preset named ${key}. Run radiocli presets to see saved names.`);
|
|
314
|
+
this.commit({ ...this.state, presets: presets.filter(preset => preset.name !== key) });
|
|
315
|
+
}
|
|
247
316
|
exportBackup(requestedPath) {
|
|
317
|
+
this.refresh();
|
|
248
318
|
const targetPath = requestedPath?.trim()
|
|
249
319
|
? resolveUserPath(requestedPath)
|
|
250
320
|
: resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
|
|
@@ -284,7 +354,9 @@ export class JsonLibraryStore {
|
|
|
284
354
|
? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
|
|
285
355
|
: undefined;
|
|
286
356
|
if (safetyBackupPath) {
|
|
287
|
-
|
|
357
|
+
// Another TUI or background player may have saved since this owner
|
|
358
|
+
// last refreshed. Preserve its latest data while holding the lock.
|
|
359
|
+
writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.read()));
|
|
288
360
|
}
|
|
289
361
|
this.write(importedState);
|
|
290
362
|
this.state = importedState;
|
|
@@ -295,6 +367,7 @@ export class JsonLibraryStore {
|
|
|
295
367
|
}
|
|
296
368
|
}
|
|
297
369
|
updateSettings(settings) {
|
|
370
|
+
this.refresh();
|
|
298
371
|
return this.commit({
|
|
299
372
|
...this.state,
|
|
300
373
|
settings: {
|
|
@@ -305,12 +378,13 @@ export class JsonLibraryStore {
|
|
|
305
378
|
});
|
|
306
379
|
}
|
|
307
380
|
updateCheckState(updateCheck) {
|
|
381
|
+
this.refresh();
|
|
308
382
|
return this.commit({ ...this.state, updateCheck });
|
|
309
383
|
}
|
|
310
384
|
addAlarm(input) {
|
|
311
385
|
const release = acquireStoreLock(this.filePath);
|
|
312
386
|
try {
|
|
313
|
-
const latestState = this.
|
|
387
|
+
const latestState = this.readForCurrentSession();
|
|
314
388
|
if (latestState.alarms.length >= MAX_ALARMS) {
|
|
315
389
|
throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
|
|
316
390
|
}
|
|
@@ -352,7 +426,7 @@ export class JsonLibraryStore {
|
|
|
352
426
|
removeAlarm(id) {
|
|
353
427
|
const release = acquireStoreLock(this.filePath);
|
|
354
428
|
try {
|
|
355
|
-
const latestState = this.
|
|
429
|
+
const latestState = this.readForCurrentSession();
|
|
356
430
|
if (!latestState.alarms.some(alarm => alarm.id === id)) {
|
|
357
431
|
this.state = latestState;
|
|
358
432
|
return false;
|
|
@@ -374,10 +448,12 @@ export class JsonLibraryStore {
|
|
|
374
448
|
}));
|
|
375
449
|
}
|
|
376
450
|
getAlarm(id) {
|
|
451
|
+
this.refresh();
|
|
377
452
|
const alarm = this.state.alarms.find(item => item.id === id);
|
|
378
453
|
return alarm ? structuredClone(alarm) : undefined;
|
|
379
454
|
}
|
|
380
455
|
listAlarms() {
|
|
456
|
+
this.refresh();
|
|
381
457
|
return structuredClone(this.state.alarms);
|
|
382
458
|
}
|
|
383
459
|
recordAlarmOutcome(id, outcome, options = {}) {
|
|
@@ -409,6 +485,7 @@ export class JsonLibraryStore {
|
|
|
409
485
|
}));
|
|
410
486
|
}
|
|
411
487
|
addRecent(station) {
|
|
488
|
+
this.refresh();
|
|
412
489
|
const key = stationKey(station);
|
|
413
490
|
const recent = [
|
|
414
491
|
{ station, playedAt: new Date().toISOString() },
|
|
@@ -419,9 +496,10 @@ export class JsonLibraryStore {
|
|
|
419
496
|
// Persist the ICY track titles a station announces so "what was that song?"
|
|
420
497
|
// is answerable later. Consecutive duplicates on the same station are skipped.
|
|
421
498
|
recordTrack(station, title) {
|
|
499
|
+
this.refresh();
|
|
422
500
|
const cleaned = title.trim();
|
|
423
501
|
if (!cleaned) {
|
|
424
|
-
return this.
|
|
502
|
+
return this.cachedSnapshot();
|
|
425
503
|
}
|
|
426
504
|
const key = stationKey(station);
|
|
427
505
|
const last = this.state.trackHistory[0];
|
|
@@ -438,6 +516,7 @@ export class JsonLibraryStore {
|
|
|
438
516
|
}
|
|
439
517
|
// Most-recent-first search queries for up-arrow recall in the search box.
|
|
440
518
|
addSearch(query) {
|
|
519
|
+
this.refresh();
|
|
441
520
|
const cleaned = query.trim();
|
|
442
521
|
if (!cleaned) {
|
|
443
522
|
return this.snapshot();
|
|
@@ -446,6 +525,7 @@ export class JsonLibraryStore {
|
|
|
446
525
|
return this.commit({ ...this.state, searchHistory });
|
|
447
526
|
}
|
|
448
527
|
startListeningSession(station, startedAt = new Date()) {
|
|
528
|
+
this.refresh();
|
|
449
529
|
const finishedState = recoverActiveListeningSessionInState(this.state);
|
|
450
530
|
const session = {
|
|
451
531
|
id: `${startedAt.toISOString()}-${stationKey(station)}`,
|
|
@@ -462,6 +542,7 @@ export class JsonLibraryStore {
|
|
|
462
542
|
});
|
|
463
543
|
}
|
|
464
544
|
checkpointActiveListeningSession(activeAt = new Date()) {
|
|
545
|
+
this.refresh();
|
|
465
546
|
const active = this.state.activity.sessions[0];
|
|
466
547
|
if (!active || active.endedAt) {
|
|
467
548
|
return this.snapshot();
|
|
@@ -482,6 +563,7 @@ export class JsonLibraryStore {
|
|
|
482
563
|
});
|
|
483
564
|
}
|
|
484
565
|
finishActiveListeningSession(endedAt = new Date()) {
|
|
566
|
+
this.refresh();
|
|
485
567
|
const nextState = finishActiveListeningSessionInState(this.state, endedAt);
|
|
486
568
|
if (nextState === this.state) {
|
|
487
569
|
return this.snapshot();
|
|
@@ -489,6 +571,7 @@ export class JsonLibraryStore {
|
|
|
489
571
|
return this.commit(nextState);
|
|
490
572
|
}
|
|
491
573
|
toggleFavorite(station) {
|
|
574
|
+
this.refresh();
|
|
492
575
|
const key = stationKey(station);
|
|
493
576
|
const exists = this.state.favorites.some(item => stationKey(item) === key);
|
|
494
577
|
const favorites = exists
|
|
@@ -497,6 +580,7 @@ export class JsonLibraryStore {
|
|
|
497
580
|
return this.commit({ ...this.state, favorites });
|
|
498
581
|
}
|
|
499
582
|
addImported(stations) {
|
|
583
|
+
this.refresh();
|
|
500
584
|
const incoming = [];
|
|
501
585
|
const incomingKeys = new Set();
|
|
502
586
|
for (const station of stations) {
|
|
@@ -518,13 +602,14 @@ export class JsonLibraryStore {
|
|
|
518
602
|
if (!station) {
|
|
519
603
|
return false;
|
|
520
604
|
}
|
|
605
|
+
this.refresh();
|
|
521
606
|
const key = stationKey(station);
|
|
522
607
|
return this.state.favorites.some(item => stationKey(item) === key);
|
|
523
608
|
}
|
|
524
609
|
mutateAlarm(id, change) {
|
|
525
610
|
const release = acquireStoreLock(this.filePath);
|
|
526
611
|
try {
|
|
527
|
-
const latestState = this.
|
|
612
|
+
const latestState = this.readForCurrentSession();
|
|
528
613
|
const existing = latestState.alarms.find(alarm => alarm.id === id);
|
|
529
614
|
if (!existing) {
|
|
530
615
|
this.state = latestState;
|
|
@@ -553,10 +638,17 @@ export class JsonLibraryStore {
|
|
|
553
638
|
return defaultState();
|
|
554
639
|
throw storageError('read the RadioCLI library', error);
|
|
555
640
|
}
|
|
641
|
+
let state;
|
|
556
642
|
try {
|
|
557
|
-
|
|
643
|
+
state = migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
|
|
558
644
|
}
|
|
559
|
-
catch {
|
|
645
|
+
catch (error) {
|
|
646
|
+
if (error instanceof z.ZodError && error.issues.some(issue => issue.code === 'invalid_value' && issue.path.length === 2 &&
|
|
647
|
+
issue.path[0] === 'settings' && (issue.path[1] === 'receiverStyle' || issue.path[1] === 'theme'))) {
|
|
648
|
+
// Another running build may have saved a newer appearance. Its library
|
|
649
|
+
// is intact: never quarantine it or replace it with empty defaults.
|
|
650
|
+
throw new Error('The RadioCLI library uses a display color or receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated colors and styles.');
|
|
651
|
+
}
|
|
560
652
|
try {
|
|
561
653
|
assertStorageWritable(this.filePath);
|
|
562
654
|
renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
|
|
@@ -567,15 +659,66 @@ export class JsonLibraryStore {
|
|
|
567
659
|
}
|
|
568
660
|
return defaultState();
|
|
569
661
|
}
|
|
662
|
+
// A legacy writer can still change ordinary preferences and favorites. Its
|
|
663
|
+
// fallback style must not erase the selection saved by an updated process.
|
|
664
|
+
// The newer namespace wins over a leftover preview preference. Continue
|
|
665
|
+
// reading the old namespace when no newer selection has been saved.
|
|
666
|
+
for (const path of [this.receiverPreferenceV2Path(), this.receiverPreferencePath()]) {
|
|
667
|
+
let preference;
|
|
668
|
+
try {
|
|
669
|
+
preference = readFileSync(path, 'utf8');
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (error.code === 'ENOENT')
|
|
673
|
+
continue;
|
|
674
|
+
throw storageError('read the receiver preference', error);
|
|
675
|
+
}
|
|
676
|
+
const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
|
|
677
|
+
if (!parsed.success)
|
|
678
|
+
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.');
|
|
679
|
+
if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
|
|
680
|
+
state.settings.receiverStyle = parsed.data.receiverStyle;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
return this.readThemePreference(state);
|
|
684
|
+
}
|
|
685
|
+
receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
|
|
686
|
+
receiverPreferenceV2Path() { return `${this.filePath}.receiver-v2.json`; }
|
|
687
|
+
themePreferencePath() { return `${this.filePath}.theme.json`; }
|
|
688
|
+
readThemePreference(state) {
|
|
689
|
+
let preference;
|
|
690
|
+
try {
|
|
691
|
+
preference = readFileSync(this.themePreferencePath(), 'utf8');
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (error.code === 'ENOENT')
|
|
695
|
+
return state;
|
|
696
|
+
throw storageError('read the display color preference', error);
|
|
697
|
+
}
|
|
698
|
+
const parsed = themePreferenceSchema.safeParse(JSON.parse(preference));
|
|
699
|
+
if (!parsed.success)
|
|
700
|
+
throw new Error('The RadioCLI display color preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
|
|
701
|
+
if (state.settings.theme === parsed.data.libraryTheme)
|
|
702
|
+
state.settings.theme = parsed.data.theme;
|
|
703
|
+
return state;
|
|
704
|
+
}
|
|
705
|
+
readForCurrentSession() {
|
|
706
|
+
const next = this.read();
|
|
707
|
+
// Disk intentionally stores auto instead of reopening a receiver on launch.
|
|
708
|
+
// Within this owner, unrelated durable changes retain its selected output.
|
|
709
|
+
if (this.state.settings.preferredBackend === 'airplay' && next.settings.preferredBackend === 'auto') {
|
|
710
|
+
next.settings.preferredBackend = 'airplay';
|
|
711
|
+
}
|
|
712
|
+
return next;
|
|
570
713
|
}
|
|
571
714
|
commit(nextState) {
|
|
572
715
|
const release = acquireStoreLock(this.filePath);
|
|
573
716
|
try {
|
|
574
|
-
const latestState = this.
|
|
717
|
+
const latestState = this.readForCurrentSession();
|
|
575
718
|
const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
|
|
576
719
|
this.write(mergedState);
|
|
577
720
|
this.state = mergedState;
|
|
578
|
-
return this.
|
|
721
|
+
return this.cachedSnapshot();
|
|
579
722
|
}
|
|
580
723
|
finally {
|
|
581
724
|
release();
|
|
@@ -585,7 +728,67 @@ export class JsonLibraryStore {
|
|
|
585
728
|
try {
|
|
586
729
|
assertStorageWritable(this.filePath);
|
|
587
730
|
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
588
|
-
|
|
731
|
+
const diskState = libraryStateForDisk(state);
|
|
732
|
+
const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle);
|
|
733
|
+
const separateTheme = !sharedLibraryThemes.has(state.settings.theme);
|
|
734
|
+
const preferences = [
|
|
735
|
+
{
|
|
736
|
+
path: this.receiverPreferenceV2Path(),
|
|
737
|
+
next: separateReceiver
|
|
738
|
+
? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
|
|
739
|
+
: undefined
|
|
740
|
+
},
|
|
741
|
+
{ path: this.receiverPreferencePath(), next: undefined },
|
|
742
|
+
{
|
|
743
|
+
path: this.themePreferencePath(),
|
|
744
|
+
next: separateTheme
|
|
745
|
+
? `${JSON.stringify({ version: 1, theme: state.settings.theme, libraryTheme: 'green' }, null, 2)}\n`
|
|
746
|
+
: undefined
|
|
747
|
+
}
|
|
748
|
+
].map(preference => {
|
|
749
|
+
let previous;
|
|
750
|
+
try {
|
|
751
|
+
previous = readFileSync(preference.path, 'utf8');
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
if (error.code !== 'ENOENT')
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
return { ...preference, previous };
|
|
758
|
+
}).filter(preference => preference.previous !== preference.next);
|
|
759
|
+
const applied = [];
|
|
760
|
+
try {
|
|
761
|
+
for (const preference of preferences) {
|
|
762
|
+
if (preference.next === undefined)
|
|
763
|
+
rmSync(preference.path);
|
|
764
|
+
else
|
|
765
|
+
writeTextAtomically(preference.path, preference.next);
|
|
766
|
+
applied.push(preference);
|
|
767
|
+
}
|
|
768
|
+
writeJsonAtomically(this.filePath, {
|
|
769
|
+
...diskState,
|
|
770
|
+
settings: {
|
|
771
|
+
...diskState.settings,
|
|
772
|
+
...(separateReceiver ? { receiverStyle: 'ultracode' } : {}),
|
|
773
|
+
...(separateTheme ? { theme: 'green' } : {})
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
for (const preference of applied.reverse()) {
|
|
779
|
+
if (preference.previous === undefined)
|
|
780
|
+
rmSync(preference.path, { force: true });
|
|
781
|
+
else
|
|
782
|
+
writeTextAtomically(preference.path, preference.previous);
|
|
783
|
+
}
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
this.revision = this.diskRevision();
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
this.revision = '';
|
|
791
|
+
}
|
|
589
792
|
}
|
|
590
793
|
catch (error) {
|
|
591
794
|
throw storageError('write the RadioCLI library', error);
|
|
@@ -593,7 +796,7 @@ export class JsonLibraryStore {
|
|
|
593
796
|
}
|
|
594
797
|
}
|
|
595
798
|
export function stationKey(station) {
|
|
596
|
-
return `${station.provider}:${station.id}`;
|
|
799
|
+
return `${station.provider}:${migrateStationIdentity(station).id}`;
|
|
597
800
|
}
|
|
598
801
|
function finishActiveListeningSessionInState(state, endedAt) {
|
|
599
802
|
const active = state.activity.sessions[0];
|
|
@@ -630,8 +833,8 @@ function recoverActiveListeningSessionInState(state) {
|
|
|
630
833
|
: Date.now();
|
|
631
834
|
return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
|
|
632
835
|
}
|
|
633
|
-
export function defaultLibraryPath() {
|
|
634
|
-
const { library, legacyLibrary } = platformPaths();
|
|
836
|
+
export function defaultLibraryPath(options = {}) {
|
|
837
|
+
const { library, legacyLibrary } = platformPaths(options);
|
|
635
838
|
for (const filePath of [library, legacyLibrary]) {
|
|
636
839
|
try {
|
|
637
840
|
statSync(filePath);
|
|
@@ -662,6 +865,7 @@ function defaultState() {
|
|
|
662
865
|
return {
|
|
663
866
|
recent: [],
|
|
664
867
|
favorites: [],
|
|
868
|
+
presets: [],
|
|
665
869
|
imported: [],
|
|
666
870
|
trackHistory: [],
|
|
667
871
|
searchHistory: [],
|
|
@@ -691,9 +895,32 @@ function defaultState() {
|
|
|
691
895
|
};
|
|
692
896
|
}
|
|
693
897
|
function migrateLibraryState(state) {
|
|
898
|
+
const aliases = new Map();
|
|
899
|
+
const migrate = (station) => {
|
|
900
|
+
const next = migrateStationIdentity(station);
|
|
901
|
+
if (next.id !== station.id) {
|
|
902
|
+
const key = `${station.provider}:${station.id}`;
|
|
903
|
+
const targets = aliases.get(key) ?? new Set();
|
|
904
|
+
targets.add(stationKey(next));
|
|
905
|
+
aliases.set(key, targets);
|
|
906
|
+
}
|
|
907
|
+
return next;
|
|
908
|
+
};
|
|
909
|
+
const recent = state.recent.map(item => ({ ...item, station: migrate(item.station) }));
|
|
910
|
+
const favorites = state.favorites.map(migrate);
|
|
911
|
+
const imported = state.imported.map(migrate);
|
|
912
|
+
const presets = state.presets?.map(item => ({ ...item, station: migrate(item.station) }));
|
|
913
|
+
const sessions = state.activity.sessions.map(item => ({ ...item, station: migrate(item.station) }));
|
|
914
|
+
const alarms = state.alarms.map(alarm => normalizeAlarm({ ...alarm, station: migrate(alarm.station),
|
|
915
|
+
playback: { ...alarm.playback, ...(alarm.playback.fallbackStation ? { fallbackStation: migrate(alarm.playback.fallbackStation) } : {}) } }));
|
|
694
916
|
return {
|
|
695
917
|
...state,
|
|
696
|
-
|
|
918
|
+
recent, favorites, imported, presets, alarms,
|
|
919
|
+
activity: { sessions },
|
|
920
|
+
trackHistory: state.trackHistory.map(item => {
|
|
921
|
+
const targets = aliases.get(item.stationKey);
|
|
922
|
+
return targets?.size === 1 ? { ...item, stationKey: [...targets][0] } : item;
|
|
923
|
+
}),
|
|
697
924
|
settings: {
|
|
698
925
|
...state.settings,
|
|
699
926
|
preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
|
|
@@ -718,14 +945,27 @@ function libraryStateForDisk(state) {
|
|
|
718
945
|
: state;
|
|
719
946
|
}
|
|
720
947
|
function writeJsonAtomically(filePath, value) {
|
|
948
|
+
writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
949
|
+
}
|
|
950
|
+
function writeTextAtomically(filePath, text) {
|
|
721
951
|
const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
722
952
|
try {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
953
|
+
const fd = openSync(tempPath, 'wx', 0o600);
|
|
954
|
+
try {
|
|
955
|
+
writeFileSync(fd, text, 'utf8');
|
|
956
|
+
if (process.platform !== 'win32')
|
|
957
|
+
chmodSync(tempPath, 0o600);
|
|
958
|
+
fsyncSync(fd);
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
try {
|
|
962
|
+
closeSync(fd);
|
|
963
|
+
}
|
|
964
|
+
catch { /* Preserve the original persistence error. */ }
|
|
965
|
+
throw error;
|
|
726
966
|
}
|
|
727
|
-
|
|
728
|
-
|
|
967
|
+
closeSync(fd);
|
|
968
|
+
replaceFileSync(tempPath, filePath);
|
|
729
969
|
}
|
|
730
970
|
catch (error) {
|
|
731
971
|
try {
|
|
@@ -734,6 +974,23 @@ function writeJsonAtomically(filePath, value) {
|
|
|
734
974
|
catch { /* Preserve the original write failure. */ }
|
|
735
975
|
throw storageError('write RadioCLI data', error);
|
|
736
976
|
}
|
|
977
|
+
// Flush the replacement directory where supported. Once rename succeeds the
|
|
978
|
+
// transaction is committed: a directory-sync failure cannot roll it back or
|
|
979
|
+
// leave the caller's in-memory state describing the previous file. Windows
|
|
980
|
+
// and some network/removable filesystems do not support syncing directories.
|
|
981
|
+
let directoryFd;
|
|
982
|
+
try {
|
|
983
|
+
directoryFd = openSync(dirname(filePath), 'r');
|
|
984
|
+
fsyncSync(directoryFd);
|
|
985
|
+
}
|
|
986
|
+
catch { /* The file itself was flushed before replacement. */ }
|
|
987
|
+
finally {
|
|
988
|
+
if (directoryFd !== undefined)
|
|
989
|
+
try {
|
|
990
|
+
closeSync(directoryFd);
|
|
991
|
+
}
|
|
992
|
+
catch { /* Already committed. */ }
|
|
993
|
+
}
|
|
737
994
|
}
|
|
738
995
|
function acquireStoreLock(filePath) {
|
|
739
996
|
const lockPath = `${filePath}.lock`;
|
|
@@ -744,37 +1001,11 @@ function acquireStoreLock(filePath) {
|
|
|
744
1001
|
catch (error) {
|
|
745
1002
|
throw storageError('write the RadioCLI library', error);
|
|
746
1003
|
}
|
|
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
|
-
}
|
|
1004
|
+
try {
|
|
1005
|
+
return acquireFileLock(lockPath);
|
|
1006
|
+
}
|
|
1007
|
+
catch (error) {
|
|
1008
|
+
throw storageError('lock the RadioCLI library', error);
|
|
778
1009
|
}
|
|
779
1010
|
}
|
|
780
1011
|
class LibraryStorageError extends Error {
|
|
@@ -794,10 +1025,17 @@ class LibraryStorageError extends Error {
|
|
|
794
1025
|
function storageError(operation, cause) {
|
|
795
1026
|
return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
|
|
796
1027
|
}
|
|
1028
|
+
function normalizePresetName(name) {
|
|
1029
|
+
const key = name.trim().toLowerCase();
|
|
1030
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(key))
|
|
1031
|
+
throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.');
|
|
1032
|
+
return key;
|
|
1033
|
+
}
|
|
797
1034
|
function mergeLibraryChanges(base, disk, next) {
|
|
798
1035
|
return {
|
|
799
1036
|
recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
|
|
800
1037
|
favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
|
|
1038
|
+
presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true),
|
|
801
1039
|
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
|
|
802
1040
|
trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
|
|
803
1041
|
searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
|
package/dist/types.js
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/receiver-style-registry.js';
|
|
2
2
|
export { defaultReceiverStyle, receiverStyleNames };
|
|
3
3
|
const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
|
|
4
|
-
export const themeNames = [
|
|
4
|
+
export const themeNames = [
|
|
5
|
+
// Reds
|
|
6
|
+
'vermilion', 'scarlet',
|
|
7
|
+
// Oranges and earth tones
|
|
8
|
+
'cocoa', 'copper', 'tangerine', 'blaze-orange', 'coral', 'apricot',
|
|
9
|
+
// Golds and creams
|
|
10
|
+
'bronze', 'amber', 'sunflower', 'butter', 'parchment', 'ivory',
|
|
11
|
+
// Yellow greens
|
|
12
|
+
'olive', 'lime', 'chartreuse', 'acid-yellow', 'pistachio',
|
|
13
|
+
// Greens
|
|
14
|
+
'pine', 'laser-green', 'green',
|
|
15
|
+
// Teals and cyans
|
|
16
|
+
'midnight-teal', 'jade', 'teal', 'cyan', 'ice',
|
|
17
|
+
// Blues
|
|
18
|
+
'cobalt', 'blue', 'slate', 'periwinkle', 'ultramarine', 'indigo',
|
|
19
|
+
// Purples and mauves
|
|
20
|
+
'neon-violet', 'violet', 'aubergine', 'plum', 'heather', 'dusty-mauve',
|
|
21
|
+
// Magentas and pinks
|
|
22
|
+
'hot-magenta', 'wineberry', 'raspberry', 'ruby', 'dracula-pink', 'rose', 'blush',
|
|
23
|
+
// Neutrals
|
|
24
|
+
'soft-silver', 'mono'
|
|
25
|
+
];
|
|
5
26
|
export const defaultAgentControlSettings = {
|
|
6
27
|
enabled: false,
|
|
7
28
|
openUiOnPlay: true,
|