@ciphore/radiocli 0.1.0

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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CODE_OF_CONDUCT.md +13 -0
  3. package/CONTRIBUTING.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +364 -0
  6. package/SECURITY.md +31 -0
  7. package/dist/activity/stats.js +122 -0
  8. package/dist/cli.js +143 -0
  9. package/dist/player/backend-install.js +122 -0
  10. package/dist/player/command.js +8 -0
  11. package/dist/player/player-controller.js +494 -0
  12. package/dist/playlists/playlist.js +169 -0
  13. package/dist/providers/cache.js +92 -0
  14. package/dist/providers/provider-manager.js +50 -0
  15. package/dist/providers/radio-browser.js +412 -0
  16. package/dist/providers/radio-garden.js +87 -0
  17. package/dist/storage/store.js +369 -0
  18. package/dist/types.js +55 -0
  19. package/dist/ui/App.js +770 -0
  20. package/dist/ui/AppContent.js +45 -0
  21. package/dist/ui/app-state.js +250 -0
  22. package/dist/ui/components/Logo.js +9 -0
  23. package/dist/ui/components/Menu.js +23 -0
  24. package/dist/ui/components/ScreenHeader.js +15 -0
  25. package/dist/ui/components/StationList.js +23 -0
  26. package/dist/ui/components/TopTabs.js +82 -0
  27. package/dist/ui/cosmo-land-data.js +4 -0
  28. package/dist/ui/cosmo-world-map.js +156 -0
  29. package/dist/ui/explore-map-layout.js +24 -0
  30. package/dist/ui/format.js +22 -0
  31. package/dist/ui/layout.js +27 -0
  32. package/dist/ui/list-window.js +8 -0
  33. package/dist/ui/page-footer.js +45 -0
  34. package/dist/ui/playback-footer.js +48 -0
  35. package/dist/ui/screen-items.js +26 -0
  36. package/dist/ui/screens/CountriesScreen.js +10 -0
  37. package/dist/ui/screens/ExploreScreen.js +44 -0
  38. package/dist/ui/screens/HomeScreen.js +9 -0
  39. package/dist/ui/screens/MapScreen.js +59 -0
  40. package/dist/ui/screens/NowPlayingScreen.js +79 -0
  41. package/dist/ui/screens/SearchScreen.js +12 -0
  42. package/dist/ui/screens/SettingsScreen.js +38 -0
  43. package/dist/ui/screens/StationScreen.js +7 -0
  44. package/dist/ui/screens/StatsScreen.js +90 -0
  45. package/dist/ui/terminal-mouse.js +38 -0
  46. package/dist/ui/theme.js +105 -0
  47. package/dist/ui/use-app-input.js +387 -0
  48. package/dist/ui/use-command-executor.js +156 -0
  49. package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
  50. package/dist/ui/world-map.js +274 -0
  51. package/docs/THIRD_PARTY_NOTICES.md +33 -0
  52. package/package.json +83 -0
@@ -0,0 +1,369 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
6
+ import { backupBadFile } from '../providers/cache.js';
7
+ const stationSchema = z
8
+ .object({
9
+ id: z.string(),
10
+ provider: z.enum(['radio-browser', 'radio-garden', 'playlist']),
11
+ name: z.string(),
12
+ country: z.string().optional(),
13
+ countryCode: z.string().optional(),
14
+ state: z.string().optional(),
15
+ city: z.string().optional(),
16
+ language: z.string().optional(),
17
+ languageCodes: z.array(z.string()).optional(),
18
+ tags: z.array(z.string()),
19
+ codec: z.string().optional(),
20
+ bitrate: z.number().optional(),
21
+ homepage: z.string().optional(),
22
+ favicon: z.string().optional(),
23
+ streamUrl: z.string().optional(),
24
+ clickCount: z.number().optional(),
25
+ votes: z.number().optional(),
26
+ latitude: z.number().optional(),
27
+ longitude: z.number().optional(),
28
+ distanceKm: z.number().optional(),
29
+ hls: z.boolean().optional(),
30
+ lastCheckedOk: z.boolean().optional()
31
+ })
32
+ .strict();
33
+ const defaultMediaKeys = {
34
+ previous: [],
35
+ playPause: [],
36
+ next: []
37
+ };
38
+ const retiredExternalStylePrefix = `${'term'}${'flix'}-`;
39
+ const removedReceiverStyles = new Set([
40
+ 'scope',
41
+ 'spectrum',
42
+ 'oscilloscope',
43
+ 'sdr',
44
+ 'signal',
45
+ 'retro',
46
+ 'neon',
47
+ 'waterfall',
48
+ 'cassette',
49
+ 'casette',
50
+ 'stars',
51
+ 'radio-waves',
52
+ 'raindrops',
53
+ 'vinyl',
54
+ 'soundwave',
55
+ 'spectrum-3d',
56
+ 'tuning-dial',
57
+ 'rf-constellation',
58
+ 'rf-constelation',
59
+ 'sphere',
60
+ 'mobius',
61
+ 's-meter',
62
+ 'jellyfish',
63
+ 'prism',
64
+ 'motion-bars',
65
+ 'motion-dots',
66
+ 'motion-braid',
67
+ 'radar',
68
+ 'blocks',
69
+ 'vu-meters',
70
+ 'spirograph',
71
+ 'dejong',
72
+ 'truchet',
73
+ `${retiredExternalStylePrefix}fire`,
74
+ `${retiredExternalStylePrefix}matrix`,
75
+ `${retiredExternalStylePrefix}plasma`,
76
+ `${retiredExternalStylePrefix}starfield`,
77
+ `${retiredExternalStylePrefix}waterfall`,
78
+ `${retiredExternalStylePrefix}radar`,
79
+ 'wave',
80
+ 'life',
81
+ 'particles',
82
+ 'pendulum',
83
+ 'rain',
84
+ 'fountain',
85
+ 'flow',
86
+ 'spiral',
87
+ 'ocean',
88
+ 'aurora',
89
+ 'lightning',
90
+ 'smoke',
91
+ 'ripple',
92
+ 'snow',
93
+ 'garden',
94
+ 'fireflies',
95
+ 'dna',
96
+ 'pulse',
97
+ 'boids',
98
+ 'lava',
99
+ 'sandstorm',
100
+ 'petals',
101
+ 'campfire',
102
+ 'eclipse',
103
+ 'blackhole',
104
+ 'rainforest',
105
+ 'crystallize',
106
+ 'hackerman',
107
+ 'visualizer',
108
+ 'cells',
109
+ 'atom',
110
+ 'automata',
111
+ 'globe',
112
+ 'dragon',
113
+ 'sierpinski',
114
+ 'sierpinksi',
115
+ 'mandelbrot',
116
+ 'maze',
117
+ 'metaballs',
118
+ 'nbody',
119
+ 'langton',
120
+ 'sort',
121
+ 'tetris',
122
+ 'snake',
123
+ 'invaders',
124
+ 'pong',
125
+ 'flappy-bird',
126
+ 'reaction-diffusion',
127
+ 'voronoi'
128
+ ]);
129
+ function normalizeReceiverStyle(value) {
130
+ return typeof value === 'string' && removedReceiverStyles.has(value) ? defaultReceiverStyle : value;
131
+ }
132
+ const settingsSchema = z.object({
133
+ theme: z.enum(themeNames).default('green'),
134
+ receiverStyle: z.preprocess(normalizeReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
135
+ receiverStyleVersion: z.number().optional(),
136
+ volume: z.number().min(0).max(100).default(70),
137
+ enableRadioGarden: z.boolean().default(false),
138
+ enableNearbyLocation: z.boolean().default(false),
139
+ preferredBackend: z.enum(['auto', 'mpv', 'ffplay']).default('auto'),
140
+ tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
141
+ skipBrokenStreams: z.boolean().default(true),
142
+ mediaKeys: z
143
+ .object({
144
+ previous: z.array(z.string()).default([]),
145
+ playPause: z.array(z.string()).default([]),
146
+ next: z.array(z.string()).default([])
147
+ })
148
+ .default(defaultMediaKeys)
149
+ });
150
+ const librarySchema = z.object({
151
+ recent: z
152
+ .array(z.object({
153
+ station: stationSchema,
154
+ playedAt: z.string()
155
+ }))
156
+ .default([]),
157
+ favorites: z.array(stationSchema).default([]),
158
+ imported: z.array(stationSchema).default([]),
159
+ activity: z
160
+ .object({
161
+ sessions: z
162
+ .array(z.object({
163
+ id: z.string(),
164
+ station: stationSchema,
165
+ startedAt: z.string(),
166
+ endedAt: z.string().optional(),
167
+ listenedSeconds: z.number().min(0)
168
+ }))
169
+ .default([])
170
+ })
171
+ .default({ sessions: [] }),
172
+ settings: settingsSchema.default({
173
+ theme: 'green',
174
+ receiverStyle: defaultReceiverStyle,
175
+ receiverStyleVersion: 2,
176
+ volume: 70,
177
+ enableRadioGarden: false,
178
+ enableNearbyLocation: false,
179
+ preferredBackend: 'auto',
180
+ tuneTimeoutSeconds: 12,
181
+ skipBrokenStreams: true,
182
+ mediaKeys: defaultMediaKeys
183
+ })
184
+ });
185
+ export class JsonLibraryStore {
186
+ filePath;
187
+ state;
188
+ constructor(filePath = defaultStorePath()) {
189
+ this.filePath = filePath;
190
+ this.state = this.read();
191
+ }
192
+ snapshot() {
193
+ return structuredClone(this.state);
194
+ }
195
+ updateSettings(settings) {
196
+ this.state = {
197
+ ...this.state,
198
+ settings: {
199
+ ...this.state.settings,
200
+ ...settings,
201
+ receiverStyleVersion: settings.receiverStyle ? 2 : this.state.settings.receiverStyleVersion
202
+ }
203
+ };
204
+ this.write();
205
+ return this.snapshot();
206
+ }
207
+ addRecent(station) {
208
+ const key = stationKey(station);
209
+ const recent = [
210
+ { station, playedAt: new Date().toISOString() },
211
+ ...this.state.recent.filter(item => stationKey(item.station) !== key)
212
+ ].slice(0, 50);
213
+ this.state = { ...this.state, recent };
214
+ this.write();
215
+ return this.snapshot();
216
+ }
217
+ startListeningSession(station, startedAt = new Date()) {
218
+ this.finishActiveListeningSession(startedAt);
219
+ const session = {
220
+ id: `${startedAt.toISOString()}-${stationKey(station)}`,
221
+ station,
222
+ startedAt: startedAt.toISOString(),
223
+ listenedSeconds: 0
224
+ };
225
+ this.state = {
226
+ ...this.state,
227
+ activity: {
228
+ sessions: [session, ...this.state.activity.sessions].slice(0, 2000)
229
+ }
230
+ };
231
+ this.write();
232
+ return this.snapshot();
233
+ }
234
+ finishActiveListeningSession(endedAt = new Date()) {
235
+ const sessions = this.state.activity.sessions.map((session, index) => {
236
+ if (index !== 0 || session.endedAt) {
237
+ return session;
238
+ }
239
+ const started = Date.parse(session.startedAt);
240
+ const ended = endedAt.getTime();
241
+ const listenedSeconds = Number.isFinite(started) && ended > started
242
+ ? Math.max(session.listenedSeconds, Math.round((ended - started) / 1000))
243
+ : session.listenedSeconds;
244
+ return {
245
+ ...session,
246
+ endedAt: endedAt.toISOString(),
247
+ listenedSeconds
248
+ };
249
+ });
250
+ this.state = { ...this.state, activity: { sessions } };
251
+ this.write();
252
+ return this.snapshot();
253
+ }
254
+ toggleFavorite(station) {
255
+ const key = stationKey(station);
256
+ const exists = this.state.favorites.some(item => stationKey(item) === key);
257
+ const favorites = exists
258
+ ? this.state.favorites.filter(item => stationKey(item) !== key)
259
+ : [station, ...this.state.favorites].slice(0, 200);
260
+ this.state = { ...this.state, favorites };
261
+ this.write();
262
+ return this.snapshot();
263
+ }
264
+ addImported(stations) {
265
+ const existing = new Map(this.state.imported.map(station => [stationKey(station), station]));
266
+ for (const station of stations) {
267
+ existing.set(stationKey(station), station);
268
+ }
269
+ this.state = {
270
+ ...this.state,
271
+ imported: [...existing.values()].slice(0, 1000)
272
+ };
273
+ this.write();
274
+ return this.snapshot();
275
+ }
276
+ isFavorite(station) {
277
+ if (!station) {
278
+ return false;
279
+ }
280
+ const key = stationKey(station);
281
+ return this.state.favorites.some(item => stationKey(item) === key);
282
+ }
283
+ read() {
284
+ if (!existsSync(this.filePath)) {
285
+ return defaultState();
286
+ }
287
+ try {
288
+ return migrateLibraryState(librarySchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8'))));
289
+ }
290
+ catch {
291
+ backupBadFile(this.filePath);
292
+ return defaultState();
293
+ }
294
+ }
295
+ write() {
296
+ mkdirSync(dirname(this.filePath), { recursive: true });
297
+ writeJsonAtomically(this.filePath, this.state);
298
+ }
299
+ }
300
+ export function stationKey(station) {
301
+ return `${station.provider}:${station.id}`;
302
+ }
303
+ function defaultStorePath() {
304
+ if (process.env.RADIOCLI_HOME) {
305
+ return join(process.env.RADIOCLI_HOME, 'radiocli.json');
306
+ }
307
+ if (process.env.RADIO_ATLAS_HOME) {
308
+ return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json');
309
+ }
310
+ const currentPath = currentDefaultStorePath();
311
+ const legacyPath = legacyDefaultStorePath();
312
+ return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
313
+ }
314
+ function currentDefaultStorePath() {
315
+ if (process.platform === 'darwin') {
316
+ return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
317
+ }
318
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
319
+ }
320
+ function legacyDefaultStorePath() {
321
+ if (process.platform === 'darwin') {
322
+ return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json');
323
+ }
324
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json');
325
+ }
326
+ function defaultState() {
327
+ return {
328
+ recent: [],
329
+ favorites: [],
330
+ imported: [],
331
+ activity: { sessions: [] },
332
+ settings: {
333
+ theme: 'green',
334
+ receiverStyle: defaultReceiverStyle,
335
+ receiverStyleVersion: 2,
336
+ volume: 70,
337
+ enableRadioGarden: false,
338
+ enableNearbyLocation: false,
339
+ preferredBackend: 'auto',
340
+ tuneTimeoutSeconds: 12,
341
+ skipBrokenStreams: true,
342
+ mediaKeys: defaultMediaKeys
343
+ }
344
+ };
345
+ }
346
+ function migrateLibraryState(state) {
347
+ if (state.settings.receiverStyleVersion === 2) {
348
+ return state;
349
+ }
350
+ return {
351
+ ...state,
352
+ settings: {
353
+ ...state.settings,
354
+ receiverStyle: defaultReceiverStyle,
355
+ receiverStyleVersion: 2
356
+ }
357
+ };
358
+ }
359
+ function writeJsonAtomically(filePath, value) {
360
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
361
+ try {
362
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
363
+ renameSync(tempPath, filePath);
364
+ }
365
+ catch (error) {
366
+ rmSync(tempPath, { force: true });
367
+ throw error;
368
+ }
369
+ }
package/dist/types.js ADDED
@@ -0,0 +1,55 @@
1
+ const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
2
+ export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
3
+ export const receiverStyleNames = [
4
+ 'equalizer',
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';