@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.
- package/CHANGELOG.md +44 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +36 -2
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +169 -103
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +26 -6
- package/dist/providers/radio-browser.js +139 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +253 -152
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +251 -47
- package/dist/ui/AppContent.js +9 -10
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +9 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +12 -1
- package/dist/ui/screen-items.js +60 -22
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +93 -21
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +29 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +24 -7
- package/dist/ui/use-command-executor.js +28 -0
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -235
package/dist/storage/store.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
289
|
-
...
|
|
243
|
+
return this.commit({
|
|
244
|
+
...finishedState,
|
|
290
245
|
activity: {
|
|
291
|
-
sessions: [session, ...
|
|
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.
|
|
295
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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';
|