@ciphore/radiocli 0.2.0 → 0.2.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +36 -2
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +9 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +93 -21
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
@@ -1,9 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
6
6
  import { backupBadFile } from '../providers/cache.js';
7
+ import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
7
8
  const stationSchema = z
8
9
  .object({
9
10
  id: z.string(),
@@ -34,110 +35,14 @@ const defaultMediaKeys = {
34
35
  playPause: [],
35
36
  next: []
36
37
  };
37
- const retiredExternalStylePrefix = `${'term'}${'flix'}-`;
38
- const removedReceiverStyles = new Set([
39
- 'scope',
40
- 'spectrum',
41
- 'oscilloscope',
42
- 'sdr',
43
- 'signal',
44
- 'retro',
45
- 'neon',
46
- 'waterfall',
47
- 'cassette',
48
- 'casette',
49
- 'stars',
50
- 'radio-waves',
51
- 'raindrops',
52
- 'vinyl',
53
- 'soundwave',
54
- 'spectrum-3d',
55
- 'tuning-dial',
56
- 'rf-constellation',
57
- 'rf-constelation',
58
- 'sphere',
59
- 'mobius',
60
- 's-meter',
61
- 'jellyfish',
62
- 'prism',
63
- 'motion-bars',
64
- 'motion-dots',
65
- 'motion-braid',
66
- 'radar',
67
- 'blocks',
68
- 'vu-meters',
69
- 'spirograph',
70
- 'dejong',
71
- 'truchet',
72
- `${retiredExternalStylePrefix}fire`,
73
- `${retiredExternalStylePrefix}matrix`,
74
- `${retiredExternalStylePrefix}plasma`,
75
- `${retiredExternalStylePrefix}starfield`,
76
- `${retiredExternalStylePrefix}waterfall`,
77
- `${retiredExternalStylePrefix}radar`,
78
- 'wave',
79
- 'life',
80
- 'particles',
81
- 'pendulum',
82
- 'rain',
83
- 'fountain',
84
- 'flow',
85
- 'spiral',
86
- 'ocean',
87
- 'aurora',
88
- 'lightning',
89
- 'smoke',
90
- 'ripple',
91
- 'snow',
92
- 'garden',
93
- 'fireflies',
94
- 'dna',
95
- 'pulse',
96
- 'boids',
97
- 'lava',
98
- 'sandstorm',
99
- 'petals',
100
- 'campfire',
101
- 'eclipse',
102
- 'blackhole',
103
- 'rainforest',
104
- 'crystallize',
105
- 'hackerman',
106
- 'visualizer',
107
- 'cells',
108
- 'atom',
109
- 'automata',
110
- 'globe',
111
- 'dragon',
112
- 'sierpinski',
113
- 'sierpinksi',
114
- 'mandelbrot',
115
- 'maze',
116
- 'metaballs',
117
- 'nbody',
118
- 'langton',
119
- 'sort',
120
- 'tetris',
121
- 'snake',
122
- 'invaders',
123
- 'pong',
124
- 'flappy-bird',
125
- 'reaction-diffusion',
126
- 'voronoi'
127
- ]);
128
- function normalizeReceiverStyle(value) {
129
- if (value === 'equalizer') {
130
- return 'ultracode';
131
- }
132
- return typeof value === 'string' && removedReceiverStyles.has(value) ? defaultReceiverStyle : value;
133
- }
134
38
  const settingsSchema = z.object({
135
39
  theme: z.enum(themeNames).default('green'),
136
- receiverStyle: z.preprocess(normalizeReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
40
+ receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
137
41
  receiverStyleVersion: z.number().optional(),
138
42
  volume: z.number().min(0).max(100).default(70),
139
43
  enableRadioGarden: z.boolean().default(false),
140
44
  enableNearbyLocation: z.boolean().default(true),
45
+ shareDirectoryVotes: z.boolean().default(true),
141
46
  preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
142
47
  preferredAirPlayDevice: z.string().min(1).optional(),
143
48
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
@@ -152,7 +57,8 @@ const settingsSchema = z.object({
152
57
  resumeOnLaunch: z.boolean().default(false),
153
58
  transparentBackground: z.boolean().default(false),
154
59
  asciiMode: z.boolean().default(false),
155
- reduceMotion: z.boolean().default(false)
60
+ reduceMotion: z.boolean().default(false),
61
+ mouseSupport: z.boolean().default(true)
156
62
  });
157
63
  const librarySchema = z.object({
158
64
  recent: z
@@ -189,6 +95,7 @@ const librarySchema = z.object({
189
95
  station: stationSchema,
190
96
  startedAt: z.string(),
191
97
  endedAt: z.string().optional(),
98
+ lastActiveAt: z.string().optional(),
192
99
  listenedSeconds: z.number().min(0)
193
100
  }))
194
101
  .default([])
@@ -201,12 +108,21 @@ const librarySchema = z.object({
201
108
  volume: 70,
202
109
  enableRadioGarden: false,
203
110
  enableNearbyLocation: true,
111
+ shareDirectoryVotes: true,
204
112
  preferredBackend: 'auto',
205
113
  tuneTimeoutSeconds: 12,
206
114
  skipBrokenStreams: true,
115
+ mouseSupport: true,
207
116
  mediaKeys: defaultMediaKeys
208
117
  })
209
118
  });
119
+ const libraryBackupSchema = z.object({
120
+ format: z.literal('radiocli-backup'),
121
+ version: z.literal(1),
122
+ exportedAt: z.string(),
123
+ library: librarySchema
124
+ });
125
+ const legacyLibraryBackupSchema = z.object({ settings: z.unknown() }).passthrough();
210
126
  export class JsonLibraryStore {
211
127
  filePath;
212
128
  state;
@@ -217,22 +133,66 @@ export class JsonLibraryStore {
217
133
  snapshot() {
218
134
  return structuredClone(this.state);
219
135
  }
136
+ exportBackup(requestedPath) {
137
+ const targetPath = requestedPath?.trim()
138
+ ? resolveUserPath(requestedPath)
139
+ : resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
140
+ if (resolve(targetPath) === resolve(this.filePath)) {
141
+ throw new Error('Choose a backup path different from the active RadioCLI library.');
142
+ }
143
+ if (existsSync(targetPath)) {
144
+ throw new Error(`Backup already exists: ${targetPath}`);
145
+ }
146
+ writeJsonAtomically(targetPath, {
147
+ format: 'radiocli-backup',
148
+ version: 1,
149
+ exportedAt: new Date().toISOString(),
150
+ library: libraryStateForDisk(this.state)
151
+ });
152
+ return targetPath;
153
+ }
154
+ importBackup(requestedPath) {
155
+ if (!requestedPath.trim()) {
156
+ throw new Error('Choose a RadioCLI backup JSON file to import.');
157
+ }
158
+ const sourcePath = resolveUserPath(requestedPath);
159
+ const sourceSize = statSync(sourcePath).size;
160
+ if (sourceSize > 25 * 1024 * 1024) {
161
+ throw new Error('RadioCLI backup is larger than the 25 MB safety limit.');
162
+ }
163
+ const parsed = JSON.parse(readFileSync(sourcePath, 'utf8'));
164
+ const backupResult = libraryBackupSchema.safeParse(parsed);
165
+ const importedState = migrateLibraryState(backupResult.success
166
+ ? backupResult.data.library
167
+ : librarySchema.parse(legacyLibraryBackupSchema.parse(parsed)));
168
+ const release = acquireStoreLock(this.filePath);
169
+ try {
170
+ const safetyBackupPath = existsSync(this.filePath)
171
+ ? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
172
+ : undefined;
173
+ if (safetyBackupPath) {
174
+ writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.state));
175
+ }
176
+ this.write(importedState);
177
+ this.state = importedState;
178
+ return { state: this.snapshot(), sourcePath, safetyBackupPath };
179
+ }
180
+ finally {
181
+ release();
182
+ }
183
+ }
220
184
  updateSettings(settings) {
221
- this.state = {
185
+ return this.commit({
222
186
  ...this.state,
223
187
  settings: {
224
188
  ...this.state.settings,
225
189
  ...settings,
226
190
  receiverStyleVersion: settings.receiverStyle ? 2 : this.state.settings.receiverStyleVersion
227
191
  }
228
- };
229
- this.write();
230
- return this.snapshot();
192
+ });
231
193
  }
232
194
  updateCheckState(updateCheck) {
233
- this.state = { ...this.state, updateCheck };
234
- this.write();
235
- return this.snapshot();
195
+ return this.commit({ ...this.state, updateCheck });
236
196
  }
237
197
  addRecent(station) {
238
198
  const key = stationKey(station);
@@ -240,9 +200,7 @@ export class JsonLibraryStore {
240
200
  { station, playedAt: new Date().toISOString() },
241
201
  ...this.state.recent.filter(item => stationKey(item.station) !== key)
242
202
  ].slice(0, 50);
243
- this.state = { ...this.state, recent };
244
- this.write();
245
- return this.snapshot();
203
+ return this.commit({ ...this.state, recent });
246
204
  }
247
205
  // Persist the ICY track titles a station announces so "what was that song?"
248
206
  // is answerable later. Consecutive duplicates on the same station are skipped.
@@ -262,9 +220,7 @@ export class JsonLibraryStore {
262
220
  stationName: station.name,
263
221
  at: new Date().toISOString()
264
222
  };
265
- this.state = { ...this.state, trackHistory: [entry, ...this.state.trackHistory].slice(0, 100) };
266
- this.write();
267
- return this.snapshot();
223
+ return this.commit({ ...this.state, trackHistory: [entry, ...this.state.trackHistory].slice(0, 100) });
268
224
  }
269
225
  // Most-recent-first search queries for up-arrow recall in the search box.
270
226
  addSearch(query) {
@@ -273,46 +229,50 @@ export class JsonLibraryStore {
273
229
  return this.snapshot();
274
230
  }
275
231
  const searchHistory = [cleaned, ...this.state.searchHistory.filter(item => item !== cleaned)].slice(0, 30);
276
- this.state = { ...this.state, searchHistory };
277
- this.write();
278
- return this.snapshot();
232
+ return this.commit({ ...this.state, searchHistory });
279
233
  }
280
234
  startListeningSession(station, startedAt = new Date()) {
281
- this.finishActiveListeningSession(startedAt);
235
+ const finishedState = recoverActiveListeningSessionInState(this.state);
282
236
  const session = {
283
237
  id: `${startedAt.toISOString()}-${stationKey(station)}`,
284
238
  station,
285
239
  startedAt: startedAt.toISOString(),
240
+ lastActiveAt: startedAt.toISOString(),
286
241
  listenedSeconds: 0
287
242
  };
288
- this.state = {
289
- ...this.state,
243
+ return this.commit({
244
+ ...finishedState,
290
245
  activity: {
291
- sessions: [session, ...this.state.activity.sessions].slice(0, 2000)
246
+ sessions: [session, ...finishedState.activity.sessions].slice(0, 2000)
292
247
  }
248
+ });
249
+ }
250
+ checkpointActiveListeningSession(activeAt = new Date()) {
251
+ const active = this.state.activity.sessions[0];
252
+ if (!active || active.endedAt) {
253
+ return this.snapshot();
254
+ }
255
+ const started = Date.parse(active.startedAt);
256
+ const checkpoint = activeAt.getTime();
257
+ if (!Number.isFinite(started) || !Number.isFinite(checkpoint) || checkpoint < started) {
258
+ return this.snapshot();
259
+ }
260
+ const updated = {
261
+ ...active,
262
+ lastActiveAt: activeAt.toISOString(),
263
+ listenedSeconds: Math.max(active.listenedSeconds, Math.round((checkpoint - started) / 1000))
293
264
  };
294
- this.write();
295
- return this.snapshot();
265
+ return this.commit({
266
+ ...this.state,
267
+ activity: { sessions: [updated, ...this.state.activity.sessions.slice(1)] }
268
+ });
296
269
  }
297
270
  finishActiveListeningSession(endedAt = new Date()) {
298
- const sessions = this.state.activity.sessions.map((session, index) => {
299
- if (index !== 0 || session.endedAt) {
300
- return session;
301
- }
302
- const started = Date.parse(session.startedAt);
303
- const ended = endedAt.getTime();
304
- const listenedSeconds = Number.isFinite(started) && ended > started
305
- ? Math.max(session.listenedSeconds, Math.round((ended - started) / 1000))
306
- : session.listenedSeconds;
307
- return {
308
- ...session,
309
- endedAt: endedAt.toISOString(),
310
- listenedSeconds
311
- };
312
- });
313
- this.state = { ...this.state, activity: { sessions } };
314
- this.write();
315
- return this.snapshot();
271
+ const nextState = finishActiveListeningSessionInState(this.state, endedAt);
272
+ if (nextState === this.state) {
273
+ return this.snapshot();
274
+ }
275
+ return this.commit(nextState);
316
276
  }
317
277
  toggleFavorite(station) {
318
278
  const key = stationKey(station);
@@ -320,21 +280,17 @@ export class JsonLibraryStore {
320
280
  const favorites = exists
321
281
  ? this.state.favorites.filter(item => stationKey(item) !== key)
322
282
  : [station, ...this.state.favorites].slice(0, 200);
323
- this.state = { ...this.state, favorites };
324
- this.write();
325
- return this.snapshot();
283
+ return this.commit({ ...this.state, favorites });
326
284
  }
327
285
  addImported(stations) {
328
286
  const existing = new Map(this.state.imported.map(station => [stationKey(station), station]));
329
287
  for (const station of stations) {
330
288
  existing.set(stationKey(station), station);
331
289
  }
332
- this.state = {
290
+ return this.commit({
333
291
  ...this.state,
334
292
  imported: [...existing.values()].slice(0, 1000)
335
- };
336
- this.write();
337
- return this.snapshot();
293
+ });
338
294
  }
339
295
  isFavorite(station) {
340
296
  if (!station) {
@@ -355,14 +311,62 @@ export class JsonLibraryStore {
355
311
  return defaultState();
356
312
  }
357
313
  }
358
- write() {
359
- mkdirSync(dirname(this.filePath), { recursive: true });
360
- writeJsonAtomically(this.filePath, libraryStateForDisk(this.state));
314
+ commit(nextState) {
315
+ const release = acquireStoreLock(this.filePath);
316
+ try {
317
+ const latestState = this.read();
318
+ const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
319
+ this.write(mergedState);
320
+ this.state = mergedState;
321
+ return this.snapshot();
322
+ }
323
+ finally {
324
+ release();
325
+ }
326
+ }
327
+ write(state) {
328
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
329
+ writeJsonAtomically(this.filePath, libraryStateForDisk(state));
361
330
  }
362
331
  }
363
332
  export function stationKey(station) {
364
333
  return `${station.provider}:${station.id}`;
365
334
  }
335
+ function finishActiveListeningSessionInState(state, endedAt) {
336
+ const active = state.activity.sessions[0];
337
+ if (!active || active.endedAt) {
338
+ return state;
339
+ }
340
+ const started = Date.parse(active.startedAt);
341
+ const ended = endedAt.getTime();
342
+ const listenedSeconds = Number.isFinite(started) && ended > started
343
+ ? Math.max(active.listenedSeconds, Math.round((ended - started) / 1000))
344
+ : active.listenedSeconds;
345
+ const finished = {
346
+ ...active,
347
+ endedAt: endedAt.toISOString(),
348
+ lastActiveAt: endedAt.toISOString(),
349
+ listenedSeconds
350
+ };
351
+ return {
352
+ ...state,
353
+ activity: { sessions: [finished, ...state.activity.sessions.slice(1)] }
354
+ };
355
+ }
356
+ function recoverActiveListeningSessionInState(state) {
357
+ const active = state.activity.sessions[0];
358
+ if (!active || active.endedAt) {
359
+ return state;
360
+ }
361
+ const started = Date.parse(active.startedAt);
362
+ const checkpoint = active.lastActiveAt ? Date.parse(active.lastActiveAt) : Number.NaN;
363
+ const recoveredEnd = Number.isFinite(checkpoint) && checkpoint >= started
364
+ ? checkpoint
365
+ : Number.isFinite(started)
366
+ ? started + Math.max(0, active.listenedSeconds) * 1000
367
+ : Date.now();
368
+ return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
369
+ }
366
370
  function defaultStorePath() {
367
371
  if (process.env.RADIOCLI_HOME) {
368
372
  return join(process.env.RADIOCLI_HOME, 'radiocli.json');
@@ -378,6 +382,9 @@ function currentDefaultStorePath() {
378
382
  if (process.platform === 'darwin') {
379
383
  return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
380
384
  }
385
+ if (process.platform === 'win32') {
386
+ return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
387
+ }
381
388
  return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
382
389
  }
383
390
  function legacyDefaultStorePath() {
@@ -386,6 +393,18 @@ function legacyDefaultStorePath() {
386
393
  }
387
394
  return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json');
388
395
  }
396
+ function resolveUserPath(requestedPath) {
397
+ const unquoted = requestedPath.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted ?? '');
398
+ const expanded = unquoted === '~'
399
+ ? homedir()
400
+ : unquoted.startsWith('~/') || unquoted.startsWith('~\\')
401
+ ? join(homedir(), unquoted.slice(2))
402
+ : unquoted;
403
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
404
+ }
405
+ function fileTimestamp(date) {
406
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').replace('T', '-');
407
+ }
389
408
  function defaultState() {
390
409
  return {
391
410
  recent: [],
@@ -401,9 +420,11 @@ function defaultState() {
401
420
  volume: 70,
402
421
  enableRadioGarden: false,
403
422
  enableNearbyLocation: true,
423
+ shareDirectoryVotes: true,
404
424
  preferredBackend: 'auto',
405
425
  tuneTimeoutSeconds: 12,
406
426
  skipBrokenStreams: true,
427
+ mouseSupport: true,
407
428
  mediaKeys: defaultMediaKeys
408
429
  }
409
430
  };
@@ -433,11 +454,91 @@ function libraryStateForDisk(state) {
433
454
  function writeJsonAtomically(filePath, value) {
434
455
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
435
456
  try {
436
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
457
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
437
458
  renameSync(tempPath, filePath);
459
+ if (process.platform !== 'win32') {
460
+ chmodSync(filePath, 0o600);
461
+ }
438
462
  }
439
463
  catch (error) {
440
464
  rmSync(tempPath, { force: true });
441
465
  throw error;
442
466
  }
443
467
  }
468
+ function acquireStoreLock(filePath) {
469
+ const lockPath = `${filePath}.lock`;
470
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
471
+ const deadline = Date.now() + 1000;
472
+ while (true) {
473
+ try {
474
+ mkdirSync(lockPath, { mode: 0o700 });
475
+ return () => rmSync(lockPath, { recursive: true, force: true });
476
+ }
477
+ catch (error) {
478
+ const code = error.code;
479
+ if (code !== 'EEXIST')
480
+ throw error;
481
+ try {
482
+ if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
483
+ rmSync(lockPath, { recursive: true, force: true });
484
+ continue;
485
+ }
486
+ }
487
+ catch {
488
+ continue;
489
+ }
490
+ if (Date.now() >= deadline) {
491
+ throw new Error(`RadioCLI library is busy: ${filePath}`);
492
+ }
493
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
494
+ }
495
+ }
496
+ }
497
+ function mergeLibraryChanges(base, disk, next) {
498
+ return {
499
+ recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
500
+ favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
501
+ imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true),
502
+ trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
503
+ searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
504
+ updateCheck: sameValue(base.updateCheck, next.updateCheck) ? disk.updateCheck : next.updateCheck,
505
+ activity: {
506
+ sessions: mergeKeyedChanges(base.activity.sessions, disk.activity.sessions, next.activity.sessions, item => item.id, 2000)
507
+ },
508
+ settings: mergeSettings(base.settings, disk.settings, next.settings)
509
+ };
510
+ }
511
+ function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
512
+ const baseByKey = new Map(base.map(item => [keyFor(item), item]));
513
+ const nextKeys = new Set(next.map(keyFor));
514
+ const removedKeys = applyRemovals
515
+ ? new Set(base.map(keyFor).filter(key => !nextKeys.has(key)))
516
+ : new Set();
517
+ const localChanges = next.filter(item => {
518
+ const prior = baseByKey.get(keyFor(item));
519
+ return prior === undefined || !sameValue(prior, item);
520
+ });
521
+ const changedKeys = new Set(localChanges.map(keyFor));
522
+ return [
523
+ ...localChanges,
524
+ ...disk.filter(item => !removedKeys.has(keyFor(item)) && !changedKeys.has(keyFor(item)))
525
+ ].slice(0, limit);
526
+ }
527
+ function mergeSearchHistory(base, disk, next) {
528
+ if (sameValue(base, next))
529
+ return disk;
530
+ return [...new Set([...next, ...disk])].slice(0, 30);
531
+ }
532
+ function mergeSettings(base, disk, next) {
533
+ const merged = { ...disk };
534
+ const baseRecord = base;
535
+ const nextRecord = next;
536
+ for (const [key, value] of Object.entries(nextRecord)) {
537
+ if (!sameValue(baseRecord[key], value))
538
+ merged[key] = value;
539
+ }
540
+ return merged;
541
+ }
542
+ function sameValue(left, right) {
543
+ return JSON.stringify(left) === JSON.stringify(right);
544
+ }
package/dist/types.js CHANGED
@@ -1,55 +1,4 @@
1
+ import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/receiver-style-registry.js';
2
+ export { defaultReceiverStyle, receiverStyleNames };
1
3
  const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
2
4
  export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
3
- export const receiverStyleNames = [
4
- 'ultracode',
5
- 'motion-blob',
6
- 'motion-area',
7
- 'motion-contour',
8
- 'leds',
9
- 'matrix',
10
- 'hologram',
11
- 'cube',
12
- 'fire',
13
- 'fireworks',
14
- 'plasma',
15
- 'spinning-donut',
16
- 'starfield',
17
- 'mesh',
18
- 'ribbon',
19
- 'orbits',
20
- 'mirror',
21
- 'tunnel',
22
- 'kaleidoscope',
23
- 'constellation',
24
- 'pulse-grid',
25
- 'lissajous',
26
- 'braille-wave',
27
- 'radial-eq',
28
- 'spectrogram',
29
- 'nebula',
30
- 'silk',
31
- 'ripple-tank',
32
- 'phyllotaxis',
33
- 'harmonograph',
34
- 'bloom-bars',
35
- 'moire',
36
- 'galaxy',
37
- 'caustics',
38
- 'lorenz',
39
- 'fern',
40
- 'chladni',
41
- 'tesseract',
42
- 'torus-knot',
43
- 'rotozoomer',
44
- 'fractal-tree',
45
- 'julia',
46
- 'clifford',
47
- 'goniometer',
48
- 'copper-bars',
49
- 'twister',
50
- 'coral',
51
- 'cyclone',
52
- 'lava-lamp',
53
- 'newton'
54
- ];
55
- export const defaultReceiverStyle = 'pulse-grid';