@ciphore/radiocli 0.1.9 → 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 (58) hide show
  1. package/CHANGELOG.md +65 -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 +53 -3
  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 +179 -98
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +28 -8
  14. package/dist/providers/radio-browser.js +141 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +264 -149
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +489 -54
  20. package/dist/ui/AppContent.js +11 -12
  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 +10 -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 +15 -2
  35. package/dist/ui/screen-items.js +60 -21
  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 +109 -10
  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 +44 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +37 -6
  52. package/dist/ui/use-command-executor.js +34 -1
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/dist/update-check.js +122 -0
  56. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  57. package/package.json +2 -2
  58. package/dist/ui/screens/screen-render.test.js +0 -214
@@ -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
@@ -172,6 +78,15 @@ const librarySchema = z.object({
172
78
  }))
173
79
  .default([]),
174
80
  searchHistory: z.array(z.string()).default([]),
81
+ updateCheck: z
82
+ .object({
83
+ checkedAt: z.string(),
84
+ currentVersion: z.string(),
85
+ latestVersion: z.string().optional(),
86
+ updateAvailable: z.boolean(),
87
+ error: z.string().optional()
88
+ })
89
+ .optional(),
175
90
  activity: z
176
91
  .object({
177
92
  sessions: z
@@ -180,6 +95,7 @@ const librarySchema = z.object({
180
95
  station: stationSchema,
181
96
  startedAt: z.string(),
182
97
  endedAt: z.string().optional(),
98
+ lastActiveAt: z.string().optional(),
183
99
  listenedSeconds: z.number().min(0)
184
100
  }))
185
101
  .default([])
@@ -192,12 +108,21 @@ const librarySchema = z.object({
192
108
  volume: 70,
193
109
  enableRadioGarden: false,
194
110
  enableNearbyLocation: true,
111
+ shareDirectoryVotes: true,
195
112
  preferredBackend: 'auto',
196
113
  tuneTimeoutSeconds: 12,
197
114
  skipBrokenStreams: true,
115
+ mouseSupport: true,
198
116
  mediaKeys: defaultMediaKeys
199
117
  })
200
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();
201
126
  export class JsonLibraryStore {
202
127
  filePath;
203
128
  state;
@@ -208,17 +133,66 @@ export class JsonLibraryStore {
208
133
  snapshot() {
209
134
  return structuredClone(this.state);
210
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
+ }
211
184
  updateSettings(settings) {
212
- this.state = {
185
+ return this.commit({
213
186
  ...this.state,
214
187
  settings: {
215
188
  ...this.state.settings,
216
189
  ...settings,
217
190
  receiverStyleVersion: settings.receiverStyle ? 2 : this.state.settings.receiverStyleVersion
218
191
  }
219
- };
220
- this.write();
221
- return this.snapshot();
192
+ });
193
+ }
194
+ updateCheckState(updateCheck) {
195
+ return this.commit({ ...this.state, updateCheck });
222
196
  }
223
197
  addRecent(station) {
224
198
  const key = stationKey(station);
@@ -226,9 +200,7 @@ export class JsonLibraryStore {
226
200
  { station, playedAt: new Date().toISOString() },
227
201
  ...this.state.recent.filter(item => stationKey(item.station) !== key)
228
202
  ].slice(0, 50);
229
- this.state = { ...this.state, recent };
230
- this.write();
231
- return this.snapshot();
203
+ return this.commit({ ...this.state, recent });
232
204
  }
233
205
  // Persist the ICY track titles a station announces so "what was that song?"
234
206
  // is answerable later. Consecutive duplicates on the same station are skipped.
@@ -248,9 +220,7 @@ export class JsonLibraryStore {
248
220
  stationName: station.name,
249
221
  at: new Date().toISOString()
250
222
  };
251
- this.state = { ...this.state, trackHistory: [entry, ...this.state.trackHistory].slice(0, 100) };
252
- this.write();
253
- return this.snapshot();
223
+ return this.commit({ ...this.state, trackHistory: [entry, ...this.state.trackHistory].slice(0, 100) });
254
224
  }
255
225
  // Most-recent-first search queries for up-arrow recall in the search box.
256
226
  addSearch(query) {
@@ -259,46 +229,50 @@ export class JsonLibraryStore {
259
229
  return this.snapshot();
260
230
  }
261
231
  const searchHistory = [cleaned, ...this.state.searchHistory.filter(item => item !== cleaned)].slice(0, 30);
262
- this.state = { ...this.state, searchHistory };
263
- this.write();
264
- return this.snapshot();
232
+ return this.commit({ ...this.state, searchHistory });
265
233
  }
266
234
  startListeningSession(station, startedAt = new Date()) {
267
- this.finishActiveListeningSession(startedAt);
235
+ const finishedState = recoverActiveListeningSessionInState(this.state);
268
236
  const session = {
269
237
  id: `${startedAt.toISOString()}-${stationKey(station)}`,
270
238
  station,
271
239
  startedAt: startedAt.toISOString(),
240
+ lastActiveAt: startedAt.toISOString(),
272
241
  listenedSeconds: 0
273
242
  };
274
- this.state = {
275
- ...this.state,
243
+ return this.commit({
244
+ ...finishedState,
276
245
  activity: {
277
- sessions: [session, ...this.state.activity.sessions].slice(0, 2000)
246
+ sessions: [session, ...finishedState.activity.sessions].slice(0, 2000)
278
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))
279
264
  };
280
- this.write();
281
- return this.snapshot();
265
+ return this.commit({
266
+ ...this.state,
267
+ activity: { sessions: [updated, ...this.state.activity.sessions.slice(1)] }
268
+ });
282
269
  }
283
270
  finishActiveListeningSession(endedAt = new Date()) {
284
- const sessions = this.state.activity.sessions.map((session, index) => {
285
- if (index !== 0 || session.endedAt) {
286
- return session;
287
- }
288
- const started = Date.parse(session.startedAt);
289
- const ended = endedAt.getTime();
290
- const listenedSeconds = Number.isFinite(started) && ended > started
291
- ? Math.max(session.listenedSeconds, Math.round((ended - started) / 1000))
292
- : session.listenedSeconds;
293
- return {
294
- ...session,
295
- endedAt: endedAt.toISOString(),
296
- listenedSeconds
297
- };
298
- });
299
- this.state = { ...this.state, activity: { sessions } };
300
- this.write();
301
- 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);
302
276
  }
303
277
  toggleFavorite(station) {
304
278
  const key = stationKey(station);
@@ -306,21 +280,17 @@ export class JsonLibraryStore {
306
280
  const favorites = exists
307
281
  ? this.state.favorites.filter(item => stationKey(item) !== key)
308
282
  : [station, ...this.state.favorites].slice(0, 200);
309
- this.state = { ...this.state, favorites };
310
- this.write();
311
- return this.snapshot();
283
+ return this.commit({ ...this.state, favorites });
312
284
  }
313
285
  addImported(stations) {
314
286
  const existing = new Map(this.state.imported.map(station => [stationKey(station), station]));
315
287
  for (const station of stations) {
316
288
  existing.set(stationKey(station), station);
317
289
  }
318
- this.state = {
290
+ return this.commit({
319
291
  ...this.state,
320
292
  imported: [...existing.values()].slice(0, 1000)
321
- };
322
- this.write();
323
- return this.snapshot();
293
+ });
324
294
  }
325
295
  isFavorite(station) {
326
296
  if (!station) {
@@ -341,14 +311,62 @@ export class JsonLibraryStore {
341
311
  return defaultState();
342
312
  }
343
313
  }
344
- write() {
345
- mkdirSync(dirname(this.filePath), { recursive: true });
346
- 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));
347
330
  }
348
331
  }
349
332
  export function stationKey(station) {
350
333
  return `${station.provider}:${station.id}`;
351
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
+ }
352
370
  function defaultStorePath() {
353
371
  if (process.env.RADIOCLI_HOME) {
354
372
  return join(process.env.RADIOCLI_HOME, 'radiocli.json');
@@ -364,6 +382,9 @@ function currentDefaultStorePath() {
364
382
  if (process.platform === 'darwin') {
365
383
  return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
366
384
  }
385
+ if (process.platform === 'win32') {
386
+ return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
387
+ }
367
388
  return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
368
389
  }
369
390
  function legacyDefaultStorePath() {
@@ -372,6 +393,18 @@ function legacyDefaultStorePath() {
372
393
  }
373
394
  return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json');
374
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
+ }
375
408
  function defaultState() {
376
409
  return {
377
410
  recent: [],
@@ -387,9 +420,11 @@ function defaultState() {
387
420
  volume: 70,
388
421
  enableRadioGarden: false,
389
422
  enableNearbyLocation: true,
423
+ shareDirectoryVotes: true,
390
424
  preferredBackend: 'auto',
391
425
  tuneTimeoutSeconds: 12,
392
426
  skipBrokenStreams: true,
427
+ mouseSupport: true,
393
428
  mediaKeys: defaultMediaKeys
394
429
  }
395
430
  };
@@ -419,11 +454,91 @@ function libraryStateForDisk(state) {
419
454
  function writeJsonAtomically(filePath, value) {
420
455
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
421
456
  try {
422
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
457
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
423
458
  renameSync(tempPath, filePath);
459
+ if (process.platform !== 'win32') {
460
+ chmodSync(filePath, 0o600);
461
+ }
424
462
  }
425
463
  catch (error) {
426
464
  rmSync(tempPath, { force: true });
427
465
  throw error;
428
466
  }
429
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';