@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.
- package/CHANGELOG.md +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- 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 +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- 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 +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- 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 +109 -10
- 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 +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -214
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
|
|
@@ -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.
|
|
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
|
-
|
|
221
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
275
|
-
...
|
|
243
|
+
return this.commit({
|
|
244
|
+
...finishedState,
|
|
276
245
|
activity: {
|
|
277
|
-
sessions: [session, ...
|
|
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.
|
|
281
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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';
|