@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/ui/App.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
|
|
4
4
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
@@ -13,26 +13,37 @@ import { AppContent } from './AppContent.js';
|
|
|
13
13
|
import { TopTabs } from './components/TopTabs.js';
|
|
14
14
|
import { computeTerminalLayout } from './layout.js';
|
|
15
15
|
import { truncate } from './format.js';
|
|
16
|
-
import { playbackFooterText,
|
|
17
|
-
import { pageFooterText } from './page-footer.js';
|
|
18
|
-
import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell } from './terminal-mouse.js';
|
|
16
|
+
import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
|
|
17
|
+
import { fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, pageFooterText } from './page-footer.js';
|
|
18
|
+
import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
|
|
19
19
|
import { useAppInput } from './use-app-input.js';
|
|
20
20
|
import { useCommandExecutor } from './use-command-executor.js';
|
|
21
21
|
import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
|
|
22
22
|
import { isAirPlayBackendAvailable } from './airplay-settings.js';
|
|
23
23
|
import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
24
24
|
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
25
|
+
import { appVersion } from '../version.js';
|
|
26
|
+
import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
|
|
27
|
+
import { helpItemCount } from './help-content.js';
|
|
28
|
+
import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
|
|
25
29
|
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextReceiverPulse, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldResetReceiverPulse, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
26
30
|
const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
|
|
27
31
|
const LIVE_RECEIVER_PULSE_MS = 80;
|
|
32
|
+
const SKYLINE_RECEIVER_PULSE_MS = 120;
|
|
28
33
|
const AMBIENT_RECEIVER_PULSE_MS = 140;
|
|
29
34
|
const LOADING_SPINNER_MS = 120;
|
|
30
35
|
const VISUALIZER_MESSAGE_MS = 4500;
|
|
36
|
+
const LISTENING_HEARTBEAT_MS = 30_000;
|
|
37
|
+
const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
38
|
+
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
39
|
+
const SEARCH_RESULTS_PAGE_SIZE = 90;
|
|
40
|
+
const SEARCH_RESULTS_LOAD_AHEAD = 12;
|
|
31
41
|
const settingToggleLabel = {
|
|
32
42
|
resumeOnLaunch: 'Resume on launch',
|
|
33
43
|
transparentBackground: 'Transparent background',
|
|
34
44
|
asciiMode: 'ASCII-safe display',
|
|
35
|
-
reduceMotion: 'Reduce motion'
|
|
45
|
+
reduceMotion: 'Reduce motion',
|
|
46
|
+
mouseSupport: 'Mouse and trackpad scrolling'
|
|
36
47
|
};
|
|
37
48
|
export function App({ store: providedStore, providers: providedProviders }) {
|
|
38
49
|
const { exit } = useApp();
|
|
@@ -41,6 +52,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
41
52
|
const { columns, rows } = useWindowSize();
|
|
42
53
|
const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
|
|
43
54
|
const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
|
|
55
|
+
const installedVersion = useMemo(() => appVersion(), []);
|
|
44
56
|
const [library, setLibrary] = useState(() => store.snapshot());
|
|
45
57
|
const settingsRef = useRef(library.settings);
|
|
46
58
|
settingsRef.current = library.settings;
|
|
@@ -61,6 +73,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
61
73
|
const [searchQuery, setSearchQuery] = useState('');
|
|
62
74
|
const [editingSearch, setEditingSearch] = useState(true);
|
|
63
75
|
const [playingStation, setPlayingStation] = useState(null);
|
|
76
|
+
const [tuningStation, setTuningStation] = useState(null);
|
|
64
77
|
const [nowPlaying, setNowPlaying] = useState(null);
|
|
65
78
|
const [location, setLocation] = useState(null);
|
|
66
79
|
const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
|
|
@@ -74,9 +87,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
74
87
|
const [sleepUntil, setSleepUntil] = useState(null);
|
|
75
88
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
76
89
|
const [capturingTransportAction, setCapturingTransportAction] = useState(null);
|
|
90
|
+
const announcedUpdateRef = useRef(false);
|
|
91
|
+
const installingUpdateRef = useRef(false);
|
|
77
92
|
const displayStationsRef = useRef([]);
|
|
78
93
|
const playbackQueueRef = useRef(null);
|
|
79
94
|
const lastRawTransportAtRef = useRef(0);
|
|
95
|
+
const loadingStationsRef = useRef(false);
|
|
96
|
+
const countryPageRequestRef = useRef(null);
|
|
97
|
+
const searchPageRequestRef = useRef(null);
|
|
80
98
|
const playStationRef = useRef(() => undefined);
|
|
81
99
|
const screenRef = useRef(screen);
|
|
82
100
|
const selectedRef = useRef(selected);
|
|
@@ -90,7 +108,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
90
108
|
const exploreMoveTimerRef = useRef(null);
|
|
91
109
|
const transientMessageTimerRef = useRef(null);
|
|
92
110
|
const transientFooterMessageTimerRef = useRef(null);
|
|
111
|
+
const exitConfirmationUntilRef = useRef(0);
|
|
93
112
|
const receiverPulseSnapshotRef = useRef(null);
|
|
113
|
+
const helpReturnScreenRef = useRef('home');
|
|
114
|
+
const countriesLoadAttemptedRef = useRef(false);
|
|
115
|
+
const tuneRequestRef = useRef(0);
|
|
116
|
+
const searchRequestRef = useRef(0);
|
|
117
|
+
const countryRequestRef = useRef(0);
|
|
118
|
+
const nearbyRequestRef = useRef(0);
|
|
119
|
+
const skipBrokenTimerRef = useRef(null);
|
|
120
|
+
const volumeRequestRef = useRef(0);
|
|
94
121
|
const theme = library.settings.theme;
|
|
95
122
|
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
96
123
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
@@ -114,6 +141,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
114
141
|
}), [library, libraryStations, stationContexts]);
|
|
115
142
|
screenRef.current = screen;
|
|
116
143
|
selectedRef.current = selected;
|
|
144
|
+
loadingStationsRef.current = loadingStations;
|
|
117
145
|
stationContextsRef.current = activeStationContexts;
|
|
118
146
|
exploreCursorRef.current = exploreCursor;
|
|
119
147
|
const renderedStationContextKey = stationContextKeyForScreen(screen);
|
|
@@ -140,7 +168,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
140
168
|
'airplay-settings': 0,
|
|
141
169
|
'airplay-code': 1,
|
|
142
170
|
settings: settingsItems.length,
|
|
143
|
-
help:
|
|
171
|
+
help: helpItemCount
|
|
144
172
|
});
|
|
145
173
|
itemCountsRef.current = {
|
|
146
174
|
home: homeItems.length,
|
|
@@ -156,37 +184,75 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
156
184
|
'airplay-settings': availableAirPlayDevices.length,
|
|
157
185
|
'airplay-code': 1,
|
|
158
186
|
settings: settingsItems.length,
|
|
159
|
-
help:
|
|
187
|
+
help: helpItemCount
|
|
160
188
|
};
|
|
161
189
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
162
190
|
displayStationsRef.current = displayStations;
|
|
163
191
|
const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
|
|
164
|
-
const
|
|
165
|
-
const
|
|
192
|
+
const footerStation = tuningStation ?? playingStation;
|
|
193
|
+
const footerPlayback = playbackStateForPendingStation(playback, tuningStation);
|
|
194
|
+
// Now Playing already owns the station identity, so its transient notice can
|
|
195
|
+
// reuse the playback-status row that other full-size screens need.
|
|
196
|
+
const footerRows = fullFooterRowCount(screen);
|
|
166
197
|
const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
|
|
167
198
|
const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
|
|
168
199
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
169
200
|
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
170
|
-
const frameWidth =
|
|
201
|
+
const frameWidth = layout.frameWidth;
|
|
202
|
+
const mouseReportingActive = !commandMode &&
|
|
203
|
+
!capturingTransportAction &&
|
|
204
|
+
!editingCountryFilter &&
|
|
205
|
+
shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
|
|
171
206
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
172
207
|
const playingStationRef = useRef(null);
|
|
173
208
|
playingStationRef.current = playingStation;
|
|
209
|
+
const activeListeningStationRef = useRef(null);
|
|
210
|
+
const lastRecordedTrackRef = useRef(null);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const station = playingStationRef.current;
|
|
213
|
+
const isAudible = playback.state === 'playing' && playback.ready && station;
|
|
214
|
+
if (isAudible) {
|
|
215
|
+
const key = stationKey(station);
|
|
216
|
+
if (activeListeningStationRef.current !== key) {
|
|
217
|
+
activeListeningStationRef.current = key;
|
|
218
|
+
setLibrary(store.startListeningSession(station));
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (activeListeningStationRef.current) {
|
|
223
|
+
activeListeningStationRef.current = null;
|
|
224
|
+
setLibrary(store.finishActiveListeningSession());
|
|
225
|
+
}
|
|
226
|
+
}, [playback.ready, playback.state, playingStation, store]);
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (playback.state !== 'playing' || !playback.ready || !playingStation) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const timer = setInterval(() => {
|
|
232
|
+
setLibrary(store.checkpointActiveListeningSession());
|
|
233
|
+
}, LISTENING_HEARTBEAT_MS);
|
|
234
|
+
return () => clearInterval(timer);
|
|
235
|
+
}, [playback.ready, playback.state, playingStation, store]);
|
|
174
236
|
useEffect(() => player.onMetadata(metadata => {
|
|
175
237
|
setNowPlaying(metadata);
|
|
176
238
|
const station = playingStationRef.current;
|
|
177
239
|
if (station && metadata.title) {
|
|
240
|
+
const trackKey = `${stationKey(station)}:${metadata.title}`;
|
|
241
|
+
if (lastRecordedTrackRef.current === trackKey)
|
|
242
|
+
return;
|
|
243
|
+
lastRecordedTrackRef.current = trackKey;
|
|
178
244
|
setLibrary(store.recordTrack(station, metadata.title));
|
|
179
245
|
}
|
|
180
246
|
}), [player, store]);
|
|
181
247
|
useEffect(() => {
|
|
182
|
-
if (
|
|
248
|
+
if (!stdout.isTTY || !mouseReportingActive) {
|
|
183
249
|
return;
|
|
184
250
|
}
|
|
185
251
|
stdout.write(enableMouseReporting);
|
|
186
252
|
return () => {
|
|
187
253
|
stdout.write(disableMouseReporting);
|
|
188
254
|
};
|
|
189
|
-
}, [
|
|
255
|
+
}, [mouseReportingActive, stdout]);
|
|
190
256
|
useEffect(() => {
|
|
191
257
|
selectedByScreenRef.current[screen] = selected;
|
|
192
258
|
if (renderedStationContextKey) {
|
|
@@ -212,12 +278,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
212
278
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
213
279
|
return;
|
|
214
280
|
}
|
|
215
|
-
const intervalMs =
|
|
281
|
+
const intervalMs = library.settings.receiverStyle === 'skyline'
|
|
282
|
+
? SKYLINE_RECEIVER_PULSE_MS
|
|
283
|
+
: LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle)
|
|
284
|
+
? LIVE_RECEIVER_PULSE_MS
|
|
285
|
+
: AMBIENT_RECEIVER_PULSE_MS;
|
|
216
286
|
const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
|
|
217
287
|
return () => clearInterval(timer);
|
|
218
288
|
}, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
|
|
219
289
|
useEffect(() => {
|
|
220
|
-
if (
|
|
290
|
+
if (footerPlayback.state !== 'loading' ||
|
|
221
291
|
library.settings.reduceMotion ||
|
|
222
292
|
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
223
293
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
@@ -226,9 +296,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
226
296
|
}
|
|
227
297
|
const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
|
|
228
298
|
return () => clearInterval(timer);
|
|
229
|
-
}, [library.settings.reduceMotion
|
|
299
|
+
}, [footerPlayback.state, library.settings.reduceMotion]);
|
|
230
300
|
useEffect(() => {
|
|
231
|
-
if ((screen === 'countries' || screen === 'map') &&
|
|
301
|
+
if ((screen === 'countries' || screen === 'map') &&
|
|
302
|
+
countries.length === 0 &&
|
|
303
|
+
!loadingCountries &&
|
|
304
|
+
!countriesLoadAttemptedRef.current) {
|
|
305
|
+
countriesLoadAttemptedRef.current = true;
|
|
232
306
|
setLoadingCountries(true);
|
|
233
307
|
providers
|
|
234
308
|
.countries()
|
|
@@ -247,6 +321,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
247
321
|
if (transientFooterMessageTimerRef.current) {
|
|
248
322
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
249
323
|
}
|
|
324
|
+
if (skipBrokenTimerRef.current) {
|
|
325
|
+
clearTimeout(skipBrokenTimerRef.current);
|
|
326
|
+
}
|
|
250
327
|
}, []);
|
|
251
328
|
useEffect(() => {
|
|
252
329
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
@@ -257,12 +334,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
257
334
|
}
|
|
258
335
|
const delayMs = sleepUntil - Date.now();
|
|
259
336
|
if (delayMs <= 0) {
|
|
337
|
+
tuneRequestRef.current += 1;
|
|
260
338
|
setLibrary(store.finishActiveListeningSession());
|
|
261
339
|
void player.stop();
|
|
262
340
|
setSleepUntil(null);
|
|
263
341
|
return;
|
|
264
342
|
}
|
|
265
343
|
const timer = setTimeout(() => {
|
|
344
|
+
tuneRequestRef.current += 1;
|
|
266
345
|
setLibrary(store.finishActiveListeningSession());
|
|
267
346
|
void player.stop();
|
|
268
347
|
setSleepUntil(null);
|
|
@@ -283,6 +362,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
283
362
|
useEffect(() => {
|
|
284
363
|
refreshProviderHealth();
|
|
285
364
|
}, [refreshProviderHealth]);
|
|
365
|
+
const refreshUpdateCheck = useCallback(async () => {
|
|
366
|
+
const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
|
|
367
|
+
setLibrary(store.updateCheckState(updateCheck));
|
|
368
|
+
return updateCheck;
|
|
369
|
+
}, [installedVersion, store]);
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (!shouldCheckForUpdate(library.updateCheck)) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
let cancelled = false;
|
|
375
|
+
void refreshUpdateCheck().then(updateCheck => {
|
|
376
|
+
if (cancelled) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (updateCheck.updateAvailable && updateCheck.latestVersion && !announcedUpdateRef.current) {
|
|
380
|
+
announcedUpdateRef.current = true;
|
|
381
|
+
setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
return () => {
|
|
385
|
+
cancelled = true;
|
|
386
|
+
};
|
|
387
|
+
}, [library.updateCheck, refreshUpdateCheck]);
|
|
286
388
|
const setStationContextFor = useCallback((key, context) => {
|
|
287
389
|
setStationContexts(current => ({ ...current, [key]: context }));
|
|
288
390
|
}, []);
|
|
@@ -324,14 +426,20 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
324
426
|
setMessage(`Learned ${mediaActionLabel(action)} key.`);
|
|
325
427
|
}, [updateMediaKeys]);
|
|
326
428
|
const go = useCallback((next, options = {}) => {
|
|
429
|
+
if (next === 'help' && screenRef.current !== 'help') {
|
|
430
|
+
helpReturnScreenRef.current = screenRef.current;
|
|
431
|
+
}
|
|
432
|
+
const destination = screenRef.current === 'help' && next === 'home'
|
|
433
|
+
? helpReturnScreenRef.current
|
|
434
|
+
: next;
|
|
327
435
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
328
|
-
const remembered = selectedByScreenRef.current[
|
|
436
|
+
const remembered = selectedByScreenRef.current[destination] ?? 0;
|
|
329
437
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
330
|
-
if (
|
|
438
|
+
if (destination === 'now-playing' && screenRef.current !== 'now-playing') {
|
|
331
439
|
setPulse(0);
|
|
332
440
|
}
|
|
333
|
-
setScreen(
|
|
334
|
-
setSelected(clamp(nextSelection, (itemCountsRef.current[
|
|
441
|
+
setScreen(destination);
|
|
442
|
+
setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
|
|
335
443
|
if (options.clearMessage !== false) {
|
|
336
444
|
setMessage(null);
|
|
337
445
|
}
|
|
@@ -346,9 +454,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
346
454
|
}
|
|
347
455
|
}, [openAirPlayCode, playback.backend, playback.message]);
|
|
348
456
|
const shutdown = useCallback(() => {
|
|
457
|
+
tuneRequestRef.current += 1;
|
|
349
458
|
store.finishActiveListeningSession();
|
|
350
459
|
player.stop().finally(exit);
|
|
351
460
|
}, [exit, player, store]);
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
const finishForSignal = () => {
|
|
463
|
+
store.finishActiveListeningSession();
|
|
464
|
+
void player.stop().finally(() => process.exit(0));
|
|
465
|
+
};
|
|
466
|
+
process.once('SIGTERM', finishForSignal);
|
|
467
|
+
process.once('SIGHUP', finishForSignal);
|
|
468
|
+
return () => {
|
|
469
|
+
process.off('SIGTERM', finishForSignal);
|
|
470
|
+
process.off('SIGHUP', finishForSignal);
|
|
471
|
+
};
|
|
472
|
+
}, [player, store]);
|
|
352
473
|
const showStationContext = useCallback((context, next = 'stations', options = {}) => {
|
|
353
474
|
setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
|
|
354
475
|
go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
|
|
@@ -431,41 +552,115 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
431
552
|
void loadExploreAt(next, { resetSelection: true, clearMessage: false });
|
|
432
553
|
}, [frameWidth, layout, loadExploreAt]);
|
|
433
554
|
const loadCountry = useCallback(async (country) => {
|
|
555
|
+
const requestId = countryRequestRef.current + 1;
|
|
556
|
+
countryRequestRef.current = requestId;
|
|
434
557
|
setLoadingStations(true);
|
|
435
558
|
setMessage(null);
|
|
559
|
+
countryPageRequestRef.current = null;
|
|
436
560
|
try {
|
|
437
|
-
const stations = await providers.byCountry(country.code,
|
|
561
|
+
const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
|
|
562
|
+
if (requestId !== countryRequestRef.current)
|
|
563
|
+
return;
|
|
438
564
|
showStationContext({
|
|
439
565
|
title: country.name,
|
|
440
|
-
subtitle:
|
|
441
|
-
stations
|
|
566
|
+
subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
|
|
567
|
+
stations,
|
|
568
|
+
country,
|
|
569
|
+
hasMore: stations.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount
|
|
442
570
|
}, 'stations');
|
|
443
571
|
}
|
|
444
572
|
catch (error) {
|
|
573
|
+
if (requestId !== countryRequestRef.current)
|
|
574
|
+
return;
|
|
445
575
|
setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`);
|
|
446
576
|
}
|
|
447
577
|
finally {
|
|
448
|
-
|
|
578
|
+
if (requestId === countryRequestRef.current)
|
|
579
|
+
setLoadingStations(false);
|
|
449
580
|
}
|
|
450
581
|
}, [providers, showStationContext]);
|
|
582
|
+
const loadMoreCountryStations = useCallback(async () => {
|
|
583
|
+
const context = stationContextsRef.current.stations;
|
|
584
|
+
const country = context.country;
|
|
585
|
+
if (!country || !context.hasMore || loadingStationsRef.current) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const offset = context.stations.length;
|
|
589
|
+
const requestKey = `${country.code}:${offset}`;
|
|
590
|
+
if (countryPageRequestRef.current === requestKey) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
countryPageRequestRef.current = requestKey;
|
|
594
|
+
setLoadingStations(true);
|
|
595
|
+
try {
|
|
596
|
+
const page = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset);
|
|
597
|
+
const latest = stationContextsRef.current.stations;
|
|
598
|
+
if (latest.country?.code !== country.code || latest.stations.length !== offset) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const stations = appendUniqueStations(latest.stations, page);
|
|
602
|
+
const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE &&
|
|
603
|
+
stations.length > latest.stations.length &&
|
|
604
|
+
stations.length < country.stationCount;
|
|
605
|
+
setStationContextFor('stations', {
|
|
606
|
+
...latest,
|
|
607
|
+
subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
|
|
608
|
+
stations,
|
|
609
|
+
hasMore
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (stationContextsRef.current.stations.country?.code === country.code) {
|
|
614
|
+
setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
if (countryPageRequestRef.current === requestKey) {
|
|
619
|
+
countryPageRequestRef.current = null;
|
|
620
|
+
setLoadingStations(false);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}, [providers, setStationContextFor]);
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
if (screen === 'stations' &&
|
|
626
|
+
stationContexts.stations.country &&
|
|
627
|
+
stationContexts.stations.hasMore &&
|
|
628
|
+
stationContexts.stations.stations.length - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
|
|
629
|
+
void loadMoreCountryStations();
|
|
630
|
+
}
|
|
631
|
+
}, [
|
|
632
|
+
loadMoreCountryStations,
|
|
633
|
+
screen,
|
|
634
|
+
selected,
|
|
635
|
+
stationContexts.stations.country,
|
|
636
|
+
stationContexts.stations.hasMore,
|
|
637
|
+
stationContexts.stations.stations.length
|
|
638
|
+
]);
|
|
451
639
|
const runSearch = useCallback(async (query = searchQuery) => {
|
|
452
640
|
if (!query.trim()) {
|
|
453
641
|
setMessage('Enter a station, genre, language, or place.');
|
|
454
642
|
return;
|
|
455
643
|
}
|
|
644
|
+
const requestId = searchRequestRef.current + 1;
|
|
645
|
+
searchRequestRef.current = requestId;
|
|
456
646
|
setLoadingStations(true);
|
|
457
647
|
setMessage(null);
|
|
458
648
|
try {
|
|
459
649
|
const stations = await providers.search(query, settingsRef.current, {
|
|
460
|
-
limit:
|
|
650
|
+
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
651
|
+
offset: 0,
|
|
461
652
|
codec: filters.codec ?? undefined,
|
|
462
653
|
language: filters.language ?? undefined,
|
|
463
654
|
minBitrate: filters.minBitrate ?? undefined
|
|
464
655
|
});
|
|
656
|
+
if (requestId !== searchRequestRef.current)
|
|
657
|
+
return;
|
|
465
658
|
setStationContextFor('search', {
|
|
466
659
|
title: `Search: ${query}`,
|
|
467
|
-
subtitle:
|
|
468
|
-
stations
|
|
660
|
+
subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
|
|
661
|
+
stations,
|
|
662
|
+
query: query.trim(),
|
|
663
|
+
hasMore: stations.length >= SEARCH_RESULTS_PAGE_SIZE
|
|
469
664
|
});
|
|
470
665
|
selectedByScreenRef.current.search = 0;
|
|
471
666
|
lastSubmittedSearchRef.current = query.trim();
|
|
@@ -475,12 +670,74 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
475
670
|
setEditingSearch(true);
|
|
476
671
|
}
|
|
477
672
|
catch (error) {
|
|
673
|
+
if (requestId !== searchRequestRef.current)
|
|
674
|
+
return;
|
|
478
675
|
setMessage(error instanceof Error ? error.message : 'Search failed.');
|
|
479
676
|
}
|
|
480
677
|
finally {
|
|
481
|
-
|
|
678
|
+
if (requestId === searchRequestRef.current)
|
|
679
|
+
setLoadingStations(false);
|
|
482
680
|
}
|
|
483
681
|
}, [filters, providers, searchQuery, setStationContextFor, store]);
|
|
682
|
+
const loadMoreSearchResults = useCallback(async () => {
|
|
683
|
+
const context = stationContextsRef.current.search;
|
|
684
|
+
const query = context.query;
|
|
685
|
+
if (!query || !context.hasMore || loadingStationsRef.current) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const offset = context.stations.length;
|
|
689
|
+
const requestKey = `${query}:${offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
|
|
690
|
+
if (searchPageRequestRef.current === requestKey) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
searchPageRequestRef.current = requestKey;
|
|
694
|
+
setLoadingStations(true);
|
|
695
|
+
try {
|
|
696
|
+
const page = await providers.search(query, settingsRef.current, {
|
|
697
|
+
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
698
|
+
offset,
|
|
699
|
+
codec: filters.codec ?? undefined,
|
|
700
|
+
language: filters.language ?? undefined,
|
|
701
|
+
minBitrate: filters.minBitrate ?? undefined
|
|
702
|
+
});
|
|
703
|
+
const latest = stationContextsRef.current.search;
|
|
704
|
+
if (latest.query !== query || latest.stations.length !== offset) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const stations = appendUniqueStations(latest.stations, page);
|
|
708
|
+
const hasMore = page.length >= SEARCH_RESULTS_PAGE_SIZE && stations.length > latest.stations.length;
|
|
709
|
+
setStationContextFor('search', {
|
|
710
|
+
...latest,
|
|
711
|
+
subtitle: formatSearchSubtitle(stations.length, hasMore),
|
|
712
|
+
stations,
|
|
713
|
+
hasMore
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
if (stationContextsRef.current.search.query === query) {
|
|
718
|
+
setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
if (searchPageRequestRef.current === requestKey) {
|
|
723
|
+
searchPageRequestRef.current = null;
|
|
724
|
+
setLoadingStations(false);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}, [filters, providers, setStationContextFor]);
|
|
728
|
+
useEffect(() => {
|
|
729
|
+
if (screen === 'search' &&
|
|
730
|
+
stationContexts.search.hasMore &&
|
|
731
|
+
stationContexts.search.stations.length - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
|
|
732
|
+
void loadMoreSearchResults();
|
|
733
|
+
}
|
|
734
|
+
}, [
|
|
735
|
+
loadMoreSearchResults,
|
|
736
|
+
screen,
|
|
737
|
+
selected,
|
|
738
|
+
stationContexts.search.hasMore,
|
|
739
|
+
stationContexts.search.stations.length
|
|
740
|
+
]);
|
|
484
741
|
const recallSearchHistory = useCallback((direction) => {
|
|
485
742
|
const history = store.snapshot().searchHistory;
|
|
486
743
|
if (history.length === 0) {
|
|
@@ -504,6 +761,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
504
761
|
}
|
|
505
762
|
}, [searchQuery, store]);
|
|
506
763
|
const loadNearby = useCallback(async () => {
|
|
764
|
+
const requestId = nearbyRequestRef.current + 1;
|
|
765
|
+
nearbyRequestRef.current = requestId;
|
|
507
766
|
setLoadingStations(true);
|
|
508
767
|
setMessage(null);
|
|
509
768
|
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
@@ -521,6 +780,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
521
780
|
return;
|
|
522
781
|
}
|
|
523
782
|
const detected = location ?? (await providers.detectLocation());
|
|
783
|
+
if (requestId !== nearbyRequestRef.current)
|
|
784
|
+
return;
|
|
524
785
|
setLocation(detected);
|
|
525
786
|
if (!detected) {
|
|
526
787
|
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
@@ -535,6 +796,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
535
796
|
return;
|
|
536
797
|
}
|
|
537
798
|
const stations = await providers.nearby(detected, 90);
|
|
799
|
+
if (requestId !== nearbyRequestRef.current)
|
|
800
|
+
return;
|
|
538
801
|
setStationContextFor('nearby', {
|
|
539
802
|
title: 'Nearby',
|
|
540
803
|
subtitle: `${[detected.city, detected.region, detected.country].filter(Boolean).join(', ')} · ${detected.source}`,
|
|
@@ -542,10 +805,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
542
805
|
});
|
|
543
806
|
}
|
|
544
807
|
catch (error) {
|
|
808
|
+
if (requestId !== nearbyRequestRef.current)
|
|
809
|
+
return;
|
|
545
810
|
setMessage(error instanceof Error ? error.message : 'Could not load nearby stations.');
|
|
546
811
|
}
|
|
547
812
|
finally {
|
|
548
|
-
|
|
813
|
+
if (requestId === nearbyRequestRef.current)
|
|
814
|
+
setLoadingStations(false);
|
|
549
815
|
}
|
|
550
816
|
}, [go, location, providers, setStationContextFor]);
|
|
551
817
|
const queueFromCurrentList = useCallback((station) => {
|
|
@@ -579,15 +845,31 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
579
845
|
}
|
|
580
846
|
}, []);
|
|
581
847
|
const playStation = useCallback(async (station, options = {}) => {
|
|
848
|
+
const requestId = tuneRequestRef.current + 1;
|
|
849
|
+
tuneRequestRef.current = requestId;
|
|
850
|
+
if (skipBrokenTimerRef.current) {
|
|
851
|
+
clearTimeout(skipBrokenTimerRef.current);
|
|
852
|
+
skipBrokenTimerRef.current = null;
|
|
853
|
+
}
|
|
582
854
|
const queue = options.queue ?? queueFromCurrentList(station);
|
|
583
|
-
|
|
855
|
+
setTuningStation(station);
|
|
584
856
|
setNowPlaying(null);
|
|
585
857
|
try {
|
|
858
|
+
// Cancel any in-flight backend startup before resolving the next tune.
|
|
859
|
+
// This prevents a slower, older request from becoming audible after a
|
|
860
|
+
// newer station was selected.
|
|
861
|
+
await player.stop();
|
|
862
|
+
if (requestId !== tuneRequestRef.current)
|
|
863
|
+
return;
|
|
586
864
|
const resolved = await providers.resolve(station);
|
|
865
|
+
if (requestId !== tuneRequestRef.current)
|
|
866
|
+
return;
|
|
587
867
|
await player.play(station, resolved.url);
|
|
868
|
+
if (requestId !== tuneRequestRef.current)
|
|
869
|
+
return;
|
|
588
870
|
setPlayingStation(station);
|
|
871
|
+
setTuningStation(null);
|
|
589
872
|
playbackQueueRef.current = queue;
|
|
590
|
-
store.startListeningSession(station);
|
|
591
873
|
const nextLibrary = store.addRecent(station);
|
|
592
874
|
if (screenRef.current === 'library') {
|
|
593
875
|
const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
|
|
@@ -604,16 +886,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
604
886
|
setMessage(null);
|
|
605
887
|
}
|
|
606
888
|
catch (error) {
|
|
889
|
+
if (requestId !== tuneRequestRef.current)
|
|
890
|
+
return;
|
|
607
891
|
const message = error instanceof Error ? error.message : 'Could not tune station.';
|
|
608
892
|
const currentList = queue.stations;
|
|
609
893
|
const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
|
|
610
894
|
const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
|
|
611
895
|
if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
|
|
612
896
|
setMessage(`${message} Skipping to ${nextStation.name}.`);
|
|
897
|
+
setTuningStation(nextStation);
|
|
613
898
|
rememberQueueSelection(queue, currentIndex + 1);
|
|
614
|
-
setTimeout(() =>
|
|
899
|
+
skipBrokenTimerRef.current = setTimeout(() => {
|
|
900
|
+
skipBrokenTimerRef.current = null;
|
|
901
|
+
playStationRef.current(nextStation, { ...options, queue });
|
|
902
|
+
}, 250);
|
|
615
903
|
return;
|
|
616
904
|
}
|
|
905
|
+
setTuningStation(null);
|
|
617
906
|
setMessage(message);
|
|
618
907
|
}
|
|
619
908
|
}, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
@@ -634,7 +923,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
634
923
|
}
|
|
635
924
|
}, [playStation, store]);
|
|
636
925
|
playStationRef.current = playStation;
|
|
637
|
-
const showTransientFooterMessage = useCallback((nextMessage) => {
|
|
926
|
+
const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
|
|
638
927
|
if (transientFooterMessageTimerRef.current) {
|
|
639
928
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
640
929
|
}
|
|
@@ -642,8 +931,17 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
642
931
|
transientFooterMessageTimerRef.current = setTimeout(() => {
|
|
643
932
|
setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
644
933
|
transientFooterMessageTimerRef.current = null;
|
|
645
|
-
},
|
|
934
|
+
}, durationMs);
|
|
646
935
|
}, []);
|
|
936
|
+
const confirmCtrlCExit = useCallback(() => {
|
|
937
|
+
const decision = ctrlCExitDecision(exitConfirmationUntilRef.current, Date.now());
|
|
938
|
+
exitConfirmationUntilRef.current = decision.armedUntil;
|
|
939
|
+
if (decision.shouldExit) {
|
|
940
|
+
shutdown();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
showTransientFooterMessage('Ctrl+C again to exit', EXIT_CONFIRMATION_MS);
|
|
944
|
+
}, [showTransientFooterMessage, shutdown]);
|
|
647
945
|
const toggleFavorite = useCallback((station) => {
|
|
648
946
|
if (!station) {
|
|
649
947
|
setMessage('Select or play a station before pressing f.');
|
|
@@ -658,7 +956,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
658
956
|
else {
|
|
659
957
|
setMessage(favoriteMessage);
|
|
660
958
|
}
|
|
661
|
-
if (!wasFavorite) {
|
|
959
|
+
if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
|
|
662
960
|
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
663
961
|
void providers.vote(station);
|
|
664
962
|
}
|
|
@@ -673,8 +971,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
673
971
|
setMessage('This station has no homepage.');
|
|
674
972
|
return;
|
|
675
973
|
}
|
|
676
|
-
openExternal(station.homepage)
|
|
677
|
-
|
|
974
|
+
setMessage(openExternal(station.homepage)
|
|
975
|
+
? `Opening homepage: ${station.name}`
|
|
976
|
+
: 'That station homepage is not a valid HTTP(S) URL.');
|
|
678
977
|
}, []);
|
|
679
978
|
const copyStationUrl = useCallback(async (station) => {
|
|
680
979
|
if (!station) {
|
|
@@ -704,16 +1003,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
704
1003
|
}, [go, player, showControlResult]);
|
|
705
1004
|
const setVolume = useCallback((volume) => {
|
|
706
1005
|
const clamped = clampVolume(volume);
|
|
1006
|
+
const requestId = volumeRequestRef.current + 1;
|
|
1007
|
+
volumeRequestRef.current = requestId;
|
|
707
1008
|
void player.setVolume(clamped).then(result => {
|
|
708
|
-
if (result.ok) {
|
|
709
|
-
updateSettings({ volume:
|
|
1009
|
+
if (result.ok && volumeRequestRef.current === requestId) {
|
|
1010
|
+
updateSettings({ volume: player.getState().volume });
|
|
710
1011
|
}
|
|
711
1012
|
showControlResult(result);
|
|
712
1013
|
});
|
|
713
1014
|
}, [player, showControlResult, updateSettings]);
|
|
714
1015
|
const adjustVolume = useCallback((delta) => {
|
|
715
|
-
|
|
716
|
-
|
|
1016
|
+
const requestId = volumeRequestRef.current + 1;
|
|
1017
|
+
volumeRequestRef.current = requestId;
|
|
1018
|
+
void player.adjustVolume(delta).then(result => {
|
|
1019
|
+
if (result.ok && volumeRequestRef.current === requestId) {
|
|
1020
|
+
updateSettings({ volume: player.getState().volume });
|
|
1021
|
+
}
|
|
1022
|
+
showControlResult(result);
|
|
1023
|
+
});
|
|
1024
|
+
}, [player, showControlResult, updateSettings]);
|
|
717
1025
|
const toggleMute = useCallback(() => {
|
|
718
1026
|
void player.toggleMute().then(showControlResult);
|
|
719
1027
|
}, [player, showControlResult]);
|
|
@@ -751,6 +1059,21 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
751
1059
|
const enableNearbyLocation = !settingsRef.current.enableNearbyLocation;
|
|
752
1060
|
updateSettings({ enableNearbyLocation });
|
|
753
1061
|
setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
|
|
1062
|
+
if (enableNearbyLocation && screenRef.current === 'nearby') {
|
|
1063
|
+
setTimeout(() => void loadNearby(), 0);
|
|
1064
|
+
}
|
|
1065
|
+
else if (!enableNearbyLocation && screenRef.current === 'nearby') {
|
|
1066
|
+
const current = stationContextsRef.current.nearby;
|
|
1067
|
+
setStationContextFor('nearby', {
|
|
1068
|
+
...current,
|
|
1069
|
+
subtitle: 'Location lookup off · showing the last loaded results'
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}, [loadNearby, setStationContextFor, updateSettings]);
|
|
1073
|
+
const toggleDirectoryVoting = useCallback(() => {
|
|
1074
|
+
const shareDirectoryVotes = !settingsRef.current.shareDirectoryVotes;
|
|
1075
|
+
updateSettings({ shareDirectoryVotes });
|
|
1076
|
+
setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
|
|
754
1077
|
}, [updateSettings]);
|
|
755
1078
|
const refreshAirPlayTargets = useCallback(async (announce = true) => {
|
|
756
1079
|
try {
|
|
@@ -848,7 +1171,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
848
1171
|
setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
|
|
849
1172
|
}, [updateSettings]);
|
|
850
1173
|
const toggleSetting = useCallback((key) => {
|
|
851
|
-
const
|
|
1174
|
+
const current = key === 'mouseSupport'
|
|
1175
|
+
? settingsRef.current.mouseSupport !== false
|
|
1176
|
+
: Boolean(settingsRef.current[key]);
|
|
1177
|
+
const next = !current;
|
|
852
1178
|
updateSettings({ [key]: next });
|
|
853
1179
|
setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
|
|
854
1180
|
}, [updateSettings]);
|
|
@@ -901,6 +1227,55 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
901
1227
|
openScreen(next.screen);
|
|
902
1228
|
}
|
|
903
1229
|
}, [openScreen, screen]);
|
|
1230
|
+
const handleUpdateCommand = useCallback(async () => {
|
|
1231
|
+
const updateCheck = library.updateCheck;
|
|
1232
|
+
if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
|
|
1233
|
+
setMessage('Checking for updates...');
|
|
1234
|
+
const latest = await refreshUpdateCheck();
|
|
1235
|
+
if (latest.error) {
|
|
1236
|
+
setMessage(`Update check failed: ${latest.error}`);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const command = updateCommandForInstall();
|
|
1241
|
+
const copied = copyToClipboard(command.command);
|
|
1242
|
+
const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
|
|
1243
|
+
const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
|
|
1244
|
+
const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
|
|
1245
|
+
setMessage(`${prefix}${copied ? 'Copied' : 'Run'} ${method} update: ${command.command}`);
|
|
1246
|
+
}, [library.updateCheck, refreshUpdateCheck, store]);
|
|
1247
|
+
const updateFromSettings = useCallback(async () => {
|
|
1248
|
+
if (installingUpdateRef.current) {
|
|
1249
|
+
setMessage('Update install already running.');
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
|
|
1253
|
+
if (!currentUpdateCheck?.updateAvailable) {
|
|
1254
|
+
setMessage('Checking for updates...');
|
|
1255
|
+
const latest = await refreshUpdateCheck();
|
|
1256
|
+
if (latest.error) {
|
|
1257
|
+
setMessage(`Update check failed: ${latest.error}`);
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (latest.updateAvailable && latest.latestVersion) {
|
|
1261
|
+
setMessage(`Update available: v${latest.latestVersion}. Press Enter on Install update.`);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
setMessage(`RadioCLI is up to date at v${installedVersion}.`);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const command = updateCommandForInstall();
|
|
1268
|
+
installingUpdateRef.current = true;
|
|
1269
|
+
setMessage(`Installing update with ${command.method === 'homebrew' ? 'Homebrew' : 'npm'}...`);
|
|
1270
|
+
const result = await installUpdate(command.command);
|
|
1271
|
+
installingUpdateRef.current = false;
|
|
1272
|
+
if (result.ok) {
|
|
1273
|
+
setMessage(`Update installed. Restart RadioCLI to use the new version.`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
|
|
1277
|
+
setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
|
|
1278
|
+
}, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
|
|
904
1279
|
const executeCommand = useCommandExecutor({
|
|
905
1280
|
beginLearningTransportKey,
|
|
906
1281
|
countries,
|
|
@@ -926,6 +1301,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
926
1301
|
store,
|
|
927
1302
|
toggleFavorite,
|
|
928
1303
|
toggleMute,
|
|
1304
|
+
updateCommand: handleUpdateCommand,
|
|
929
1305
|
updateSettings
|
|
930
1306
|
});
|
|
931
1307
|
const playAdjacent = useCallback((direction) => {
|
|
@@ -961,6 +1337,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
961
1337
|
capturingTransportAction,
|
|
962
1338
|
commandMode,
|
|
963
1339
|
commandText,
|
|
1340
|
+
confirmCtrlCExit,
|
|
964
1341
|
currentItemCount,
|
|
965
1342
|
cycleDisplayColor,
|
|
966
1343
|
cycleAudioOutput,
|
|
@@ -1017,24 +1394,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1017
1394
|
togglePause,
|
|
1018
1395
|
toggleSetting,
|
|
1019
1396
|
toggleNearbyLocation,
|
|
1397
|
+
toggleDirectoryVoting,
|
|
1020
1398
|
toggleRadioGarden,
|
|
1021
|
-
toggleSkipBrokenStreams
|
|
1399
|
+
toggleSkipBrokenStreams,
|
|
1400
|
+
updateFromSettings
|
|
1022
1401
|
});
|
|
1023
1402
|
function currentItemCount(currentScreen) {
|
|
1024
1403
|
return itemCountsRef.current[currentScreen] ?? 0;
|
|
1025
1404
|
}
|
|
1026
|
-
const hasTopTabs =
|
|
1027
|
-
const globalFooter =
|
|
1028
|
-
|
|
1029
|
-
:
|
|
1030
|
-
|
|
1031
|
-
: '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit';
|
|
1032
|
-
const playbackFooter = playbackFooterText({
|
|
1033
|
-
station: playingStation,
|
|
1034
|
-
playback,
|
|
1405
|
+
const hasTopTabs = layout.mode === 'full';
|
|
1406
|
+
const globalFooter = '←/→ tabs · F7/F9 or ,/. station · t/v display · ? help · q quit';
|
|
1407
|
+
const playbackFooter = screen === 'now-playing' ? null : playbackFooterText({
|
|
1408
|
+
station: footerStation,
|
|
1409
|
+
playback: footerPlayback,
|
|
1035
1410
|
metadata: nowPlaying,
|
|
1036
1411
|
queue: playbackQueueRef.current,
|
|
1037
|
-
favorite: store.isFavorite(
|
|
1412
|
+
favorite: store.isFavorite(footerStation),
|
|
1038
1413
|
sleepLabel,
|
|
1039
1414
|
width: frameWidth,
|
|
1040
1415
|
spinnerFrame
|
|
@@ -1049,7 +1424,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1049
1424
|
playbackBackend: playback.backend,
|
|
1050
1425
|
screen
|
|
1051
1426
|
});
|
|
1052
|
-
|
|
1427
|
+
const pageFooterOwnsCompactRow = Boolean(commandMode ||
|
|
1428
|
+
capturingTransportAction ||
|
|
1429
|
+
editingCountryFilter ||
|
|
1430
|
+
(screen === 'search' && editingSearch) ||
|
|
1431
|
+
screen === 'airplay-code');
|
|
1432
|
+
const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
|
|
1433
|
+
const compactFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow ? pageFooter : playbackFooter ?? pageFooter);
|
|
1434
|
+
const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
|
|
1435
|
+
? pageFooter
|
|
1436
|
+
: footerPlayback.state === 'loading' && playbackFooter
|
|
1437
|
+
? playbackFooter
|
|
1438
|
+
: hasActiveMicroPlayback
|
|
1439
|
+
? microPlaybackControlsText(playback.backend)
|
|
1440
|
+
: pageFooter);
|
|
1441
|
+
const compactGlobalFooter = `←/→ tabs · ? help · q quit · v${installedVersion}`;
|
|
1442
|
+
const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
|
|
1443
|
+
return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: truncate(statusRow.text, frameWidth) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: truncate(pageFooter, frameWidth) }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: truncate(globalFooter, Math.max(1, frameWidth - installedVersion.length - 3)) }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { color: textDim, children: ["v", installedVersion] })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || message ? themeAccent(theme) : textMuted, children: truncate(layout.mode === 'micro' ? microFooter : compactFooter, frameWidth) }), layout.footerRows > 1 ? _jsx(Text, { color: textDim, children: truncate(compactGlobalFooter, frameWidth) }) : null] })) })] }) }));
|
|
1053
1444
|
}
|
|
1054
1445
|
function buildLibraryStations(library) {
|
|
1055
1446
|
const stations = [];
|
|
@@ -1080,6 +1471,31 @@ function exploreCursorLocation(cursor) {
|
|
|
1080
1471
|
source: 'explore cursor'
|
|
1081
1472
|
};
|
|
1082
1473
|
}
|
|
1474
|
+
function appendUniqueStations(current, page) {
|
|
1475
|
+
const stations = [...current];
|
|
1476
|
+
const seen = new Set(current.map(stationKey));
|
|
1477
|
+
for (const station of page) {
|
|
1478
|
+
const key = stationKey(station);
|
|
1479
|
+
if (!seen.has(key)) {
|
|
1480
|
+
seen.add(key);
|
|
1481
|
+
stations.push(station);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return stations;
|
|
1485
|
+
}
|
|
1486
|
+
function formatCountryStationsSubtitle(country, loaded, hasMore) {
|
|
1487
|
+
const total = country.stationCount.toLocaleString();
|
|
1488
|
+
const loadedLabel = loaded.toLocaleString();
|
|
1489
|
+
return hasMore
|
|
1490
|
+
? `${country.code} · ${loadedLabel} of ${total} listed stations loaded`
|
|
1491
|
+
: `${country.code} · ${loadedLabel} of ${total} listed stations`;
|
|
1492
|
+
}
|
|
1493
|
+
function formatSearchSubtitle(loaded, hasMore) {
|
|
1494
|
+
const count = loaded.toLocaleString();
|
|
1495
|
+
return hasMore
|
|
1496
|
+
? `Matches across enabled public station directories · ${count}+ loaded`
|
|
1497
|
+
: `Matches across enabled public station directories · ${count} loaded`;
|
|
1498
|
+
}
|
|
1083
1499
|
function formatExploreSubtitle(cursor, stations) {
|
|
1084
1500
|
if (stations.length === 0) {
|
|
1085
1501
|
return `No geotagged stations near ${formatExploreCursor(cursor)}`;
|
|
@@ -1138,3 +1554,22 @@ function audioOutputSwitchLabel(output, backends) {
|
|
|
1138
1554
|
function librarySubtitle(library) {
|
|
1139
1555
|
return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
|
|
1140
1556
|
}
|
|
1557
|
+
function mouseVisibleRows(screen, layout) {
|
|
1558
|
+
if (screen === 'help')
|
|
1559
|
+
return Math.max(3, layout.contentRows - 4);
|
|
1560
|
+
if (layout.compact)
|
|
1561
|
+
return Math.max(1, layout.contentRows - 3);
|
|
1562
|
+
if (screen === 'countries')
|
|
1563
|
+
return layout.countryRows;
|
|
1564
|
+
if (screen === 'map')
|
|
1565
|
+
return layout.mapCountryRows;
|
|
1566
|
+
if (screen === 'search')
|
|
1567
|
+
return Math.max(1, layout.contentRows - 8);
|
|
1568
|
+
if (screen === 'settings')
|
|
1569
|
+
return Math.max(5, layout.contentRows - 10);
|
|
1570
|
+
if (screen === 'airplay-settings')
|
|
1571
|
+
return Math.max(1, layout.contentRows - 9);
|
|
1572
|
+
if (['stations', 'nearby', 'explore', 'library'].includes(screen))
|
|
1573
|
+
return layout.stationRows;
|
|
1574
|
+
return layout.contentRows;
|
|
1575
|
+
}
|