@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/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,9 +13,9 @@ 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';
|
|
@@ -24,12 +24,16 @@ import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
|
24
24
|
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
25
25
|
import { appVersion } from '../version.js';
|
|
26
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';
|
|
27
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';
|
|
28
30
|
const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
|
|
29
31
|
const LIVE_RECEIVER_PULSE_MS = 80;
|
|
32
|
+
const SKYLINE_RECEIVER_PULSE_MS = 120;
|
|
30
33
|
const AMBIENT_RECEIVER_PULSE_MS = 140;
|
|
31
34
|
const LOADING_SPINNER_MS = 120;
|
|
32
35
|
const VISUALIZER_MESSAGE_MS = 4500;
|
|
36
|
+
const LISTENING_HEARTBEAT_MS = 30_000;
|
|
33
37
|
const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
34
38
|
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
35
39
|
const SEARCH_RESULTS_PAGE_SIZE = 90;
|
|
@@ -38,7 +42,8 @@ const settingToggleLabel = {
|
|
|
38
42
|
resumeOnLaunch: 'Resume on launch',
|
|
39
43
|
transparentBackground: 'Transparent background',
|
|
40
44
|
asciiMode: 'ASCII-safe display',
|
|
41
|
-
reduceMotion: 'Reduce motion'
|
|
45
|
+
reduceMotion: 'Reduce motion',
|
|
46
|
+
mouseSupport: 'Mouse and trackpad scrolling'
|
|
42
47
|
};
|
|
43
48
|
export function App({ store: providedStore, providers: providedProviders }) {
|
|
44
49
|
const { exit } = useApp();
|
|
@@ -68,6 +73,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
68
73
|
const [searchQuery, setSearchQuery] = useState('');
|
|
69
74
|
const [editingSearch, setEditingSearch] = useState(true);
|
|
70
75
|
const [playingStation, setPlayingStation] = useState(null);
|
|
76
|
+
const [tuningStation, setTuningStation] = useState(null);
|
|
71
77
|
const [nowPlaying, setNowPlaying] = useState(null);
|
|
72
78
|
const [location, setLocation] = useState(null);
|
|
73
79
|
const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
|
|
@@ -102,7 +108,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
102
108
|
const exploreMoveTimerRef = useRef(null);
|
|
103
109
|
const transientMessageTimerRef = useRef(null);
|
|
104
110
|
const transientFooterMessageTimerRef = useRef(null);
|
|
111
|
+
const exitConfirmationUntilRef = useRef(0);
|
|
105
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);
|
|
106
121
|
const theme = library.settings.theme;
|
|
107
122
|
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
108
123
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
@@ -153,7 +168,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
153
168
|
'airplay-settings': 0,
|
|
154
169
|
'airplay-code': 1,
|
|
155
170
|
settings: settingsItems.length,
|
|
156
|
-
help:
|
|
171
|
+
help: helpItemCount
|
|
157
172
|
});
|
|
158
173
|
itemCountsRef.current = {
|
|
159
174
|
home: homeItems.length,
|
|
@@ -169,37 +184,75 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
169
184
|
'airplay-settings': availableAirPlayDevices.length,
|
|
170
185
|
'airplay-code': 1,
|
|
171
186
|
settings: settingsItems.length,
|
|
172
|
-
help:
|
|
187
|
+
help: helpItemCount
|
|
173
188
|
};
|
|
174
189
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
175
190
|
displayStationsRef.current = displayStations;
|
|
176
191
|
const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
|
|
177
|
-
const
|
|
178
|
-
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);
|
|
179
197
|
const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
|
|
180
198
|
const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
|
|
181
199
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
182
200
|
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
183
|
-
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);
|
|
184
206
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
185
207
|
const playingStationRef = useRef(null);
|
|
186
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]);
|
|
187
236
|
useEffect(() => player.onMetadata(metadata => {
|
|
188
237
|
setNowPlaying(metadata);
|
|
189
238
|
const station = playingStationRef.current;
|
|
190
239
|
if (station && metadata.title) {
|
|
240
|
+
const trackKey = `${stationKey(station)}:${metadata.title}`;
|
|
241
|
+
if (lastRecordedTrackRef.current === trackKey)
|
|
242
|
+
return;
|
|
243
|
+
lastRecordedTrackRef.current = trackKey;
|
|
191
244
|
setLibrary(store.recordTrack(station, metadata.title));
|
|
192
245
|
}
|
|
193
246
|
}), [player, store]);
|
|
194
247
|
useEffect(() => {
|
|
195
|
-
if (!stdout.isTTY) {
|
|
248
|
+
if (!stdout.isTTY || !mouseReportingActive) {
|
|
196
249
|
return;
|
|
197
250
|
}
|
|
198
251
|
stdout.write(enableMouseReporting);
|
|
199
252
|
return () => {
|
|
200
253
|
stdout.write(disableMouseReporting);
|
|
201
254
|
};
|
|
202
|
-
}, [stdout]);
|
|
255
|
+
}, [mouseReportingActive, stdout]);
|
|
203
256
|
useEffect(() => {
|
|
204
257
|
selectedByScreenRef.current[screen] = selected;
|
|
205
258
|
if (renderedStationContextKey) {
|
|
@@ -225,12 +278,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
225
278
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
226
279
|
return;
|
|
227
280
|
}
|
|
228
|
-
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;
|
|
229
286
|
const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
|
|
230
287
|
return () => clearInterval(timer);
|
|
231
288
|
}, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
|
|
232
289
|
useEffect(() => {
|
|
233
|
-
if (
|
|
290
|
+
if (footerPlayback.state !== 'loading' ||
|
|
234
291
|
library.settings.reduceMotion ||
|
|
235
292
|
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
236
293
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
@@ -239,9 +296,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
239
296
|
}
|
|
240
297
|
const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
|
|
241
298
|
return () => clearInterval(timer);
|
|
242
|
-
}, [library.settings.reduceMotion
|
|
299
|
+
}, [footerPlayback.state, library.settings.reduceMotion]);
|
|
243
300
|
useEffect(() => {
|
|
244
|
-
if ((screen === 'countries' || screen === 'map') &&
|
|
301
|
+
if ((screen === 'countries' || screen === 'map') &&
|
|
302
|
+
countries.length === 0 &&
|
|
303
|
+
!loadingCountries &&
|
|
304
|
+
!countriesLoadAttemptedRef.current) {
|
|
305
|
+
countriesLoadAttemptedRef.current = true;
|
|
245
306
|
setLoadingCountries(true);
|
|
246
307
|
providers
|
|
247
308
|
.countries()
|
|
@@ -260,6 +321,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
260
321
|
if (transientFooterMessageTimerRef.current) {
|
|
261
322
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
262
323
|
}
|
|
324
|
+
if (skipBrokenTimerRef.current) {
|
|
325
|
+
clearTimeout(skipBrokenTimerRef.current);
|
|
326
|
+
}
|
|
263
327
|
}, []);
|
|
264
328
|
useEffect(() => {
|
|
265
329
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
@@ -270,12 +334,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
270
334
|
}
|
|
271
335
|
const delayMs = sleepUntil - Date.now();
|
|
272
336
|
if (delayMs <= 0) {
|
|
337
|
+
tuneRequestRef.current += 1;
|
|
273
338
|
setLibrary(store.finishActiveListeningSession());
|
|
274
339
|
void player.stop();
|
|
275
340
|
setSleepUntil(null);
|
|
276
341
|
return;
|
|
277
342
|
}
|
|
278
343
|
const timer = setTimeout(() => {
|
|
344
|
+
tuneRequestRef.current += 1;
|
|
279
345
|
setLibrary(store.finishActiveListeningSession());
|
|
280
346
|
void player.stop();
|
|
281
347
|
setSleepUntil(null);
|
|
@@ -360,14 +426,20 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
360
426
|
setMessage(`Learned ${mediaActionLabel(action)} key.`);
|
|
361
427
|
}, [updateMediaKeys]);
|
|
362
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;
|
|
363
435
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
364
|
-
const remembered = selectedByScreenRef.current[
|
|
436
|
+
const remembered = selectedByScreenRef.current[destination] ?? 0;
|
|
365
437
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
366
|
-
if (
|
|
438
|
+
if (destination === 'now-playing' && screenRef.current !== 'now-playing') {
|
|
367
439
|
setPulse(0);
|
|
368
440
|
}
|
|
369
|
-
setScreen(
|
|
370
|
-
setSelected(clamp(nextSelection, (itemCountsRef.current[
|
|
441
|
+
setScreen(destination);
|
|
442
|
+
setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
|
|
371
443
|
if (options.clearMessage !== false) {
|
|
372
444
|
setMessage(null);
|
|
373
445
|
}
|
|
@@ -382,9 +454,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
382
454
|
}
|
|
383
455
|
}, [openAirPlayCode, playback.backend, playback.message]);
|
|
384
456
|
const shutdown = useCallback(() => {
|
|
457
|
+
tuneRequestRef.current += 1;
|
|
385
458
|
store.finishActiveListeningSession();
|
|
386
459
|
player.stop().finally(exit);
|
|
387
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]);
|
|
388
473
|
const showStationContext = useCallback((context, next = 'stations', options = {}) => {
|
|
389
474
|
setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
|
|
390
475
|
go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
|
|
@@ -467,11 +552,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
467
552
|
void loadExploreAt(next, { resetSelection: true, clearMessage: false });
|
|
468
553
|
}, [frameWidth, layout, loadExploreAt]);
|
|
469
554
|
const loadCountry = useCallback(async (country) => {
|
|
555
|
+
const requestId = countryRequestRef.current + 1;
|
|
556
|
+
countryRequestRef.current = requestId;
|
|
470
557
|
setLoadingStations(true);
|
|
471
558
|
setMessage(null);
|
|
472
559
|
countryPageRequestRef.current = null;
|
|
473
560
|
try {
|
|
474
561
|
const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
|
|
562
|
+
if (requestId !== countryRequestRef.current)
|
|
563
|
+
return;
|
|
475
564
|
showStationContext({
|
|
476
565
|
title: country.name,
|
|
477
566
|
subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
|
|
@@ -481,10 +570,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
481
570
|
}, 'stations');
|
|
482
571
|
}
|
|
483
572
|
catch (error) {
|
|
573
|
+
if (requestId !== countryRequestRef.current)
|
|
574
|
+
return;
|
|
484
575
|
setMessage(error instanceof Error ? error.message : `Could not load ${country.name}.`);
|
|
485
576
|
}
|
|
486
577
|
finally {
|
|
487
|
-
|
|
578
|
+
if (requestId === countryRequestRef.current)
|
|
579
|
+
setLoadingStations(false);
|
|
488
580
|
}
|
|
489
581
|
}, [providers, showStationContext]);
|
|
490
582
|
const loadMoreCountryStations = useCallback(async () => {
|
|
@@ -507,7 +599,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
507
599
|
return;
|
|
508
600
|
}
|
|
509
601
|
const stations = appendUniqueStations(latest.stations, page);
|
|
510
|
-
const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE &&
|
|
602
|
+
const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE &&
|
|
603
|
+
stations.length > latest.stations.length &&
|
|
604
|
+
stations.length < country.stationCount;
|
|
511
605
|
setStationContextFor('stations', {
|
|
512
606
|
...latest,
|
|
513
607
|
subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
|
|
@@ -547,6 +641,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
547
641
|
setMessage('Enter a station, genre, language, or place.');
|
|
548
642
|
return;
|
|
549
643
|
}
|
|
644
|
+
const requestId = searchRequestRef.current + 1;
|
|
645
|
+
searchRequestRef.current = requestId;
|
|
550
646
|
setLoadingStations(true);
|
|
551
647
|
setMessage(null);
|
|
552
648
|
try {
|
|
@@ -557,6 +653,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
557
653
|
language: filters.language ?? undefined,
|
|
558
654
|
minBitrate: filters.minBitrate ?? undefined
|
|
559
655
|
});
|
|
656
|
+
if (requestId !== searchRequestRef.current)
|
|
657
|
+
return;
|
|
560
658
|
setStationContextFor('search', {
|
|
561
659
|
title: `Search: ${query}`,
|
|
562
660
|
subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
|
|
@@ -572,10 +670,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
572
670
|
setEditingSearch(true);
|
|
573
671
|
}
|
|
574
672
|
catch (error) {
|
|
673
|
+
if (requestId !== searchRequestRef.current)
|
|
674
|
+
return;
|
|
575
675
|
setMessage(error instanceof Error ? error.message : 'Search failed.');
|
|
576
676
|
}
|
|
577
677
|
finally {
|
|
578
|
-
|
|
678
|
+
if (requestId === searchRequestRef.current)
|
|
679
|
+
setLoadingStations(false);
|
|
579
680
|
}
|
|
580
681
|
}, [filters, providers, searchQuery, setStationContextFor, store]);
|
|
581
682
|
const loadMoreSearchResults = useCallback(async () => {
|
|
@@ -660,6 +761,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
660
761
|
}
|
|
661
762
|
}, [searchQuery, store]);
|
|
662
763
|
const loadNearby = useCallback(async () => {
|
|
764
|
+
const requestId = nearbyRequestRef.current + 1;
|
|
765
|
+
nearbyRequestRef.current = requestId;
|
|
663
766
|
setLoadingStations(true);
|
|
664
767
|
setMessage(null);
|
|
665
768
|
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
@@ -677,6 +780,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
677
780
|
return;
|
|
678
781
|
}
|
|
679
782
|
const detected = location ?? (await providers.detectLocation());
|
|
783
|
+
if (requestId !== nearbyRequestRef.current)
|
|
784
|
+
return;
|
|
680
785
|
setLocation(detected);
|
|
681
786
|
if (!detected) {
|
|
682
787
|
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
@@ -691,6 +796,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
691
796
|
return;
|
|
692
797
|
}
|
|
693
798
|
const stations = await providers.nearby(detected, 90);
|
|
799
|
+
if (requestId !== nearbyRequestRef.current)
|
|
800
|
+
return;
|
|
694
801
|
setStationContextFor('nearby', {
|
|
695
802
|
title: 'Nearby',
|
|
696
803
|
subtitle: `${[detected.city, detected.region, detected.country].filter(Boolean).join(', ')} · ${detected.source}`,
|
|
@@ -698,10 +805,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
698
805
|
});
|
|
699
806
|
}
|
|
700
807
|
catch (error) {
|
|
808
|
+
if (requestId !== nearbyRequestRef.current)
|
|
809
|
+
return;
|
|
701
810
|
setMessage(error instanceof Error ? error.message : 'Could not load nearby stations.');
|
|
702
811
|
}
|
|
703
812
|
finally {
|
|
704
|
-
|
|
813
|
+
if (requestId === nearbyRequestRef.current)
|
|
814
|
+
setLoadingStations(false);
|
|
705
815
|
}
|
|
706
816
|
}, [go, location, providers, setStationContextFor]);
|
|
707
817
|
const queueFromCurrentList = useCallback((station) => {
|
|
@@ -735,14 +845,31 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
735
845
|
}
|
|
736
846
|
}, []);
|
|
737
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
|
+
}
|
|
738
854
|
const queue = options.queue ?? queueFromCurrentList(station);
|
|
855
|
+
setTuningStation(station);
|
|
739
856
|
setNowPlaying(null);
|
|
740
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;
|
|
741
864
|
const resolved = await providers.resolve(station);
|
|
865
|
+
if (requestId !== tuneRequestRef.current)
|
|
866
|
+
return;
|
|
742
867
|
await player.play(station, resolved.url);
|
|
868
|
+
if (requestId !== tuneRequestRef.current)
|
|
869
|
+
return;
|
|
743
870
|
setPlayingStation(station);
|
|
871
|
+
setTuningStation(null);
|
|
744
872
|
playbackQueueRef.current = queue;
|
|
745
|
-
store.startListeningSession(station);
|
|
746
873
|
const nextLibrary = store.addRecent(station);
|
|
747
874
|
if (screenRef.current === 'library') {
|
|
748
875
|
const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
|
|
@@ -759,16 +886,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
759
886
|
setMessage(null);
|
|
760
887
|
}
|
|
761
888
|
catch (error) {
|
|
889
|
+
if (requestId !== tuneRequestRef.current)
|
|
890
|
+
return;
|
|
762
891
|
const message = error instanceof Error ? error.message : 'Could not tune station.';
|
|
763
892
|
const currentList = queue.stations;
|
|
764
893
|
const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
|
|
765
894
|
const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
|
|
766
895
|
if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
|
|
767
896
|
setMessage(`${message} Skipping to ${nextStation.name}.`);
|
|
897
|
+
setTuningStation(nextStation);
|
|
768
898
|
rememberQueueSelection(queue, currentIndex + 1);
|
|
769
|
-
setTimeout(() =>
|
|
899
|
+
skipBrokenTimerRef.current = setTimeout(() => {
|
|
900
|
+
skipBrokenTimerRef.current = null;
|
|
901
|
+
playStationRef.current(nextStation, { ...options, queue });
|
|
902
|
+
}, 250);
|
|
770
903
|
return;
|
|
771
904
|
}
|
|
905
|
+
setTuningStation(null);
|
|
772
906
|
setMessage(message);
|
|
773
907
|
}
|
|
774
908
|
}, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
@@ -789,7 +923,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
789
923
|
}
|
|
790
924
|
}, [playStation, store]);
|
|
791
925
|
playStationRef.current = playStation;
|
|
792
|
-
const showTransientFooterMessage = useCallback((nextMessage) => {
|
|
926
|
+
const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
|
|
793
927
|
if (transientFooterMessageTimerRef.current) {
|
|
794
928
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
795
929
|
}
|
|
@@ -797,8 +931,17 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
797
931
|
transientFooterMessageTimerRef.current = setTimeout(() => {
|
|
798
932
|
setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
799
933
|
transientFooterMessageTimerRef.current = null;
|
|
800
|
-
},
|
|
934
|
+
}, durationMs);
|
|
801
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]);
|
|
802
945
|
const toggleFavorite = useCallback((station) => {
|
|
803
946
|
if (!station) {
|
|
804
947
|
setMessage('Select or play a station before pressing f.');
|
|
@@ -813,7 +956,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
813
956
|
else {
|
|
814
957
|
setMessage(favoriteMessage);
|
|
815
958
|
}
|
|
816
|
-
if (!wasFavorite) {
|
|
959
|
+
if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
|
|
817
960
|
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
818
961
|
void providers.vote(station);
|
|
819
962
|
}
|
|
@@ -828,8 +971,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
828
971
|
setMessage('This station has no homepage.');
|
|
829
972
|
return;
|
|
830
973
|
}
|
|
831
|
-
openExternal(station.homepage)
|
|
832
|
-
|
|
974
|
+
setMessage(openExternal(station.homepage)
|
|
975
|
+
? `Opening homepage: ${station.name}`
|
|
976
|
+
: 'That station homepage is not a valid HTTP(S) URL.');
|
|
833
977
|
}, []);
|
|
834
978
|
const copyStationUrl = useCallback(async (station) => {
|
|
835
979
|
if (!station) {
|
|
@@ -859,16 +1003,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
859
1003
|
}, [go, player, showControlResult]);
|
|
860
1004
|
const setVolume = useCallback((volume) => {
|
|
861
1005
|
const clamped = clampVolume(volume);
|
|
1006
|
+
const requestId = volumeRequestRef.current + 1;
|
|
1007
|
+
volumeRequestRef.current = requestId;
|
|
862
1008
|
void player.setVolume(clamped).then(result => {
|
|
863
|
-
if (result.ok) {
|
|
864
|
-
updateSettings({ volume:
|
|
1009
|
+
if (result.ok && volumeRequestRef.current === requestId) {
|
|
1010
|
+
updateSettings({ volume: player.getState().volume });
|
|
865
1011
|
}
|
|
866
1012
|
showControlResult(result);
|
|
867
1013
|
});
|
|
868
1014
|
}, [player, showControlResult, updateSettings]);
|
|
869
1015
|
const adjustVolume = useCallback((delta) => {
|
|
870
|
-
|
|
871
|
-
|
|
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]);
|
|
872
1025
|
const toggleMute = useCallback(() => {
|
|
873
1026
|
void player.toggleMute().then(showControlResult);
|
|
874
1027
|
}, [player, showControlResult]);
|
|
@@ -906,6 +1059,21 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
906
1059
|
const enableNearbyLocation = !settingsRef.current.enableNearbyLocation;
|
|
907
1060
|
updateSettings({ enableNearbyLocation });
|
|
908
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'}.`);
|
|
909
1077
|
}, [updateSettings]);
|
|
910
1078
|
const refreshAirPlayTargets = useCallback(async (announce = true) => {
|
|
911
1079
|
try {
|
|
@@ -1003,7 +1171,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1003
1171
|
setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
|
|
1004
1172
|
}, [updateSettings]);
|
|
1005
1173
|
const toggleSetting = useCallback((key) => {
|
|
1006
|
-
const
|
|
1174
|
+
const current = key === 'mouseSupport'
|
|
1175
|
+
? settingsRef.current.mouseSupport !== false
|
|
1176
|
+
: Boolean(settingsRef.current[key]);
|
|
1177
|
+
const next = !current;
|
|
1007
1178
|
updateSettings({ [key]: next });
|
|
1008
1179
|
setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
|
|
1009
1180
|
}, [updateSettings]);
|
|
@@ -1166,6 +1337,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1166
1337
|
capturingTransportAction,
|
|
1167
1338
|
commandMode,
|
|
1168
1339
|
commandText,
|
|
1340
|
+
confirmCtrlCExit,
|
|
1169
1341
|
currentItemCount,
|
|
1170
1342
|
cycleDisplayColor,
|
|
1171
1343
|
cycleAudioOutput,
|
|
@@ -1222,6 +1394,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1222
1394
|
togglePause,
|
|
1223
1395
|
toggleSetting,
|
|
1224
1396
|
toggleNearbyLocation,
|
|
1397
|
+
toggleDirectoryVoting,
|
|
1225
1398
|
toggleRadioGarden,
|
|
1226
1399
|
toggleSkipBrokenStreams,
|
|
1227
1400
|
updateFromSettings
|
|
@@ -1229,18 +1402,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1229
1402
|
function currentItemCount(currentScreen) {
|
|
1230
1403
|
return itemCountsRef.current[currentScreen] ?? 0;
|
|
1231
1404
|
}
|
|
1232
|
-
const hasTopTabs =
|
|
1233
|
-
const globalFooter =
|
|
1234
|
-
|
|
1235
|
-
:
|
|
1236
|
-
|
|
1237
|
-
: '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit';
|
|
1238
|
-
const playbackFooter = playbackFooterText({
|
|
1239
|
-
station: playingStation,
|
|
1240
|
-
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,
|
|
1241
1410
|
metadata: nowPlaying,
|
|
1242
1411
|
queue: playbackQueueRef.current,
|
|
1243
|
-
favorite: store.isFavorite(
|
|
1412
|
+
favorite: store.isFavorite(footerStation),
|
|
1244
1413
|
sleepLabel,
|
|
1245
1414
|
width: frameWidth,
|
|
1246
1415
|
spinnerFrame
|
|
@@ -1255,7 +1424,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1255
1424
|
playbackBackend: playback.backend,
|
|
1256
1425
|
screen
|
|
1257
1426
|
});
|
|
1258
|
-
|
|
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] })) })] }) }));
|
|
1259
1444
|
}
|
|
1260
1445
|
function buildLibraryStations(library) {
|
|
1261
1446
|
const stations = [];
|
|
@@ -1369,3 +1554,22 @@ function audioOutputSwitchLabel(output, backends) {
|
|
|
1369
1554
|
function librarySubtitle(library) {
|
|
1370
1555
|
return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
|
|
1371
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
|
+
}
|