@ciphore/radiocli 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +1 -1
- package/dist/cli.js +17 -1
- package/dist/player/player-controller.js +16 -1
- package/dist/providers/provider-manager.js +3 -3
- package/dist/providers/radio-browser.js +3 -1
- package/dist/storage/store.js +18 -5
- package/dist/ui/App.js +278 -14
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/components/Menu.js +2 -1
- package/dist/ui/components/StationList.js +2 -2
- package/dist/ui/components/TopTabs.js +2 -2
- package/dist/ui/help-content.js +1 -0
- package/dist/ui/layout.js +2 -1
- package/dist/ui/page-footer.js +5 -4
- package/dist/ui/playback-footer.js +3 -1
- package/dist/ui/screen-items.js +1 -0
- package/dist/ui/screens/CountriesScreen.js +7 -1
- package/dist/ui/screens/SettingsScreen.js +33 -6
- package/dist/ui/screens/StatsScreen.js +34 -16
- package/dist/ui/screens/screen-render.test.js +54 -2
- package/dist/ui/terminal-mouse.js +15 -0
- package/dist/ui/use-app-input.js +22 -4
- package/dist/ui/use-command-executor.js +6 -1
- package/dist/update-check.js +122 -0
- package/package.json +1 -1
package/dist/ui/App.js
CHANGED
|
@@ -22,12 +22,18 @@ 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';
|
|
25
27
|
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
28
|
const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
|
|
27
29
|
const LIVE_RECEIVER_PULSE_MS = 80;
|
|
28
30
|
const AMBIENT_RECEIVER_PULSE_MS = 140;
|
|
29
31
|
const LOADING_SPINNER_MS = 120;
|
|
30
32
|
const VISUALIZER_MESSAGE_MS = 4500;
|
|
33
|
+
const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
34
|
+
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
35
|
+
const SEARCH_RESULTS_PAGE_SIZE = 90;
|
|
36
|
+
const SEARCH_RESULTS_LOAD_AHEAD = 12;
|
|
31
37
|
const settingToggleLabel = {
|
|
32
38
|
resumeOnLaunch: 'Resume on launch',
|
|
33
39
|
transparentBackground: 'Transparent background',
|
|
@@ -41,6 +47,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
41
47
|
const { columns, rows } = useWindowSize();
|
|
42
48
|
const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
|
|
43
49
|
const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
|
|
50
|
+
const installedVersion = useMemo(() => appVersion(), []);
|
|
44
51
|
const [library, setLibrary] = useState(() => store.snapshot());
|
|
45
52
|
const settingsRef = useRef(library.settings);
|
|
46
53
|
settingsRef.current = library.settings;
|
|
@@ -51,6 +58,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
51
58
|
const [screen, setScreen] = useState('home');
|
|
52
59
|
const [selected, setSelected] = useState(0);
|
|
53
60
|
const [message, setMessage] = useState(null);
|
|
61
|
+
const [footerMessage, setFooterMessage] = useState(null);
|
|
54
62
|
const [countries, setCountries] = useState([]);
|
|
55
63
|
const [countryFilter, setCountryFilter] = useState('');
|
|
56
64
|
const [editingCountryFilter, setEditingCountryFilter] = useState(false);
|
|
@@ -73,9 +81,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
73
81
|
const [sleepUntil, setSleepUntil] = useState(null);
|
|
74
82
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
75
83
|
const [capturingTransportAction, setCapturingTransportAction] = useState(null);
|
|
84
|
+
const announcedUpdateRef = useRef(false);
|
|
85
|
+
const installingUpdateRef = useRef(false);
|
|
76
86
|
const displayStationsRef = useRef([]);
|
|
77
87
|
const playbackQueueRef = useRef(null);
|
|
78
88
|
const lastRawTransportAtRef = useRef(0);
|
|
89
|
+
const loadingStationsRef = useRef(false);
|
|
90
|
+
const countryPageRequestRef = useRef(null);
|
|
91
|
+
const searchPageRequestRef = useRef(null);
|
|
79
92
|
const playStationRef = useRef(() => undefined);
|
|
80
93
|
const screenRef = useRef(screen);
|
|
81
94
|
const selectedRef = useRef(selected);
|
|
@@ -88,6 +101,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
88
101
|
const exploreRequestRef = useRef(0);
|
|
89
102
|
const exploreMoveTimerRef = useRef(null);
|
|
90
103
|
const transientMessageTimerRef = useRef(null);
|
|
104
|
+
const transientFooterMessageTimerRef = useRef(null);
|
|
91
105
|
const receiverPulseSnapshotRef = useRef(null);
|
|
92
106
|
const theme = library.settings.theme;
|
|
93
107
|
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
@@ -112,6 +126,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
112
126
|
}), [library, libraryStations, stationContexts]);
|
|
113
127
|
screenRef.current = screen;
|
|
114
128
|
selectedRef.current = selected;
|
|
129
|
+
loadingStationsRef.current = loadingStations;
|
|
115
130
|
stationContextsRef.current = activeStationContexts;
|
|
116
131
|
exploreCursorRef.current = exploreCursor;
|
|
117
132
|
const renderedStationContextKey = stationContextKeyForScreen(screen);
|
|
@@ -160,10 +175,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
160
175
|
displayStationsRef.current = displayStations;
|
|
161
176
|
const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
|
|
162
177
|
const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
|
|
178
|
+
const footerRows = showPlaybackFooter ? 4 : 3;
|
|
163
179
|
const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
|
|
164
180
|
const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
|
|
165
181
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
166
|
-
const layout = computeTerminalLayout(columns, rows,
|
|
182
|
+
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
167
183
|
const frameWidth = Math.max(40, layout.columns - 2);
|
|
168
184
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
169
185
|
const playingStationRef = useRef(null);
|
|
@@ -176,14 +192,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
176
192
|
}
|
|
177
193
|
}), [player, store]);
|
|
178
194
|
useEffect(() => {
|
|
179
|
-
if (
|
|
195
|
+
if (!stdout.isTTY) {
|
|
180
196
|
return;
|
|
181
197
|
}
|
|
182
198
|
stdout.write(enableMouseReporting);
|
|
183
199
|
return () => {
|
|
184
200
|
stdout.write(disableMouseReporting);
|
|
185
201
|
};
|
|
186
|
-
}, [
|
|
202
|
+
}, [stdout]);
|
|
187
203
|
useEffect(() => {
|
|
188
204
|
selectedByScreenRef.current[screen] = selected;
|
|
189
205
|
if (renderedStationContextKey) {
|
|
@@ -241,6 +257,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
241
257
|
if (transientMessageTimerRef.current) {
|
|
242
258
|
clearTimeout(transientMessageTimerRef.current);
|
|
243
259
|
}
|
|
260
|
+
if (transientFooterMessageTimerRef.current) {
|
|
261
|
+
clearTimeout(transientFooterMessageTimerRef.current);
|
|
262
|
+
}
|
|
244
263
|
}, []);
|
|
245
264
|
useEffect(() => {
|
|
246
265
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
@@ -277,6 +296,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
277
296
|
useEffect(() => {
|
|
278
297
|
refreshProviderHealth();
|
|
279
298
|
}, [refreshProviderHealth]);
|
|
299
|
+
const refreshUpdateCheck = useCallback(async () => {
|
|
300
|
+
const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
|
|
301
|
+
setLibrary(store.updateCheckState(updateCheck));
|
|
302
|
+
return updateCheck;
|
|
303
|
+
}, [installedVersion, store]);
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (!shouldCheckForUpdate(library.updateCheck)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let cancelled = false;
|
|
309
|
+
void refreshUpdateCheck().then(updateCheck => {
|
|
310
|
+
if (cancelled) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (updateCheck.updateAvailable && updateCheck.latestVersion && !announcedUpdateRef.current) {
|
|
314
|
+
announcedUpdateRef.current = true;
|
|
315
|
+
setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
return () => {
|
|
319
|
+
cancelled = true;
|
|
320
|
+
};
|
|
321
|
+
}, [library.updateCheck, refreshUpdateCheck]);
|
|
280
322
|
const setStationContextFor = useCallback((key, context) => {
|
|
281
323
|
setStationContexts(current => ({ ...current, [key]: context }));
|
|
282
324
|
}, []);
|
|
@@ -321,6 +363,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
321
363
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
322
364
|
const remembered = selectedByScreenRef.current[next] ?? 0;
|
|
323
365
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
366
|
+
if (next === 'now-playing' && screenRef.current !== 'now-playing') {
|
|
367
|
+
setPulse(0);
|
|
368
|
+
}
|
|
324
369
|
setScreen(next);
|
|
325
370
|
setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
|
|
326
371
|
if (options.clearMessage !== false) {
|
|
@@ -424,12 +469,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
424
469
|
const loadCountry = useCallback(async (country) => {
|
|
425
470
|
setLoadingStations(true);
|
|
426
471
|
setMessage(null);
|
|
472
|
+
countryPageRequestRef.current = null;
|
|
427
473
|
try {
|
|
428
|
-
const stations = await providers.byCountry(country.code,
|
|
474
|
+
const stations = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0);
|
|
429
475
|
showStationContext({
|
|
430
476
|
title: country.name,
|
|
431
|
-
subtitle:
|
|
432
|
-
stations
|
|
477
|
+
subtitle: formatCountryStationsSubtitle(country, stations.length, stations.length < country.stationCount),
|
|
478
|
+
stations,
|
|
479
|
+
country,
|
|
480
|
+
hasMore: stations.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount
|
|
433
481
|
}, 'stations');
|
|
434
482
|
}
|
|
435
483
|
catch (error) {
|
|
@@ -439,6 +487,61 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
439
487
|
setLoadingStations(false);
|
|
440
488
|
}
|
|
441
489
|
}, [providers, showStationContext]);
|
|
490
|
+
const loadMoreCountryStations = useCallback(async () => {
|
|
491
|
+
const context = stationContextsRef.current.stations;
|
|
492
|
+
const country = context.country;
|
|
493
|
+
if (!country || !context.hasMore || loadingStationsRef.current) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const offset = context.stations.length;
|
|
497
|
+
const requestKey = `${country.code}:${offset}`;
|
|
498
|
+
if (countryPageRequestRef.current === requestKey) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
countryPageRequestRef.current = requestKey;
|
|
502
|
+
setLoadingStations(true);
|
|
503
|
+
try {
|
|
504
|
+
const page = await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset);
|
|
505
|
+
const latest = stationContextsRef.current.stations;
|
|
506
|
+
if (latest.country?.code !== country.code || latest.stations.length !== offset) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const stations = appendUniqueStations(latest.stations, page);
|
|
510
|
+
const hasMore = page.length >= COUNTRY_STATIONS_PAGE_SIZE && stations.length < country.stationCount;
|
|
511
|
+
setStationContextFor('stations', {
|
|
512
|
+
...latest,
|
|
513
|
+
subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
|
|
514
|
+
stations,
|
|
515
|
+
hasMore
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (stationContextsRef.current.stations.country?.code === country.code) {
|
|
520
|
+
setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
if (countryPageRequestRef.current === requestKey) {
|
|
525
|
+
countryPageRequestRef.current = null;
|
|
526
|
+
setLoadingStations(false);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}, [providers, setStationContextFor]);
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
if (screen === 'stations' &&
|
|
532
|
+
stationContexts.stations.country &&
|
|
533
|
+
stationContexts.stations.hasMore &&
|
|
534
|
+
stationContexts.stations.stations.length - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
|
|
535
|
+
void loadMoreCountryStations();
|
|
536
|
+
}
|
|
537
|
+
}, [
|
|
538
|
+
loadMoreCountryStations,
|
|
539
|
+
screen,
|
|
540
|
+
selected,
|
|
541
|
+
stationContexts.stations.country,
|
|
542
|
+
stationContexts.stations.hasMore,
|
|
543
|
+
stationContexts.stations.stations.length
|
|
544
|
+
]);
|
|
442
545
|
const runSearch = useCallback(async (query = searchQuery) => {
|
|
443
546
|
if (!query.trim()) {
|
|
444
547
|
setMessage('Enter a station, genre, language, or place.');
|
|
@@ -448,15 +551,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
448
551
|
setMessage(null);
|
|
449
552
|
try {
|
|
450
553
|
const stations = await providers.search(query, settingsRef.current, {
|
|
451
|
-
limit:
|
|
554
|
+
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
555
|
+
offset: 0,
|
|
452
556
|
codec: filters.codec ?? undefined,
|
|
453
557
|
language: filters.language ?? undefined,
|
|
454
558
|
minBitrate: filters.minBitrate ?? undefined
|
|
455
559
|
});
|
|
456
560
|
setStationContextFor('search', {
|
|
457
561
|
title: `Search: ${query}`,
|
|
458
|
-
subtitle:
|
|
459
|
-
stations
|
|
562
|
+
subtitle: formatSearchSubtitle(stations.length, stations.length >= SEARCH_RESULTS_PAGE_SIZE),
|
|
563
|
+
stations,
|
|
564
|
+
query: query.trim(),
|
|
565
|
+
hasMore: stations.length >= SEARCH_RESULTS_PAGE_SIZE
|
|
460
566
|
});
|
|
461
567
|
selectedByScreenRef.current.search = 0;
|
|
462
568
|
lastSubmittedSearchRef.current = query.trim();
|
|
@@ -472,6 +578,65 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
472
578
|
setLoadingStations(false);
|
|
473
579
|
}
|
|
474
580
|
}, [filters, providers, searchQuery, setStationContextFor, store]);
|
|
581
|
+
const loadMoreSearchResults = useCallback(async () => {
|
|
582
|
+
const context = stationContextsRef.current.search;
|
|
583
|
+
const query = context.query;
|
|
584
|
+
if (!query || !context.hasMore || loadingStationsRef.current) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const offset = context.stations.length;
|
|
588
|
+
const requestKey = `${query}:${offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
|
|
589
|
+
if (searchPageRequestRef.current === requestKey) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
searchPageRequestRef.current = requestKey;
|
|
593
|
+
setLoadingStations(true);
|
|
594
|
+
try {
|
|
595
|
+
const page = await providers.search(query, settingsRef.current, {
|
|
596
|
+
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
597
|
+
offset,
|
|
598
|
+
codec: filters.codec ?? undefined,
|
|
599
|
+
language: filters.language ?? undefined,
|
|
600
|
+
minBitrate: filters.minBitrate ?? undefined
|
|
601
|
+
});
|
|
602
|
+
const latest = stationContextsRef.current.search;
|
|
603
|
+
if (latest.query !== query || latest.stations.length !== offset) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const stations = appendUniqueStations(latest.stations, page);
|
|
607
|
+
const hasMore = page.length >= SEARCH_RESULTS_PAGE_SIZE && stations.length > latest.stations.length;
|
|
608
|
+
setStationContextFor('search', {
|
|
609
|
+
...latest,
|
|
610
|
+
subtitle: formatSearchSubtitle(stations.length, hasMore),
|
|
611
|
+
stations,
|
|
612
|
+
hasMore
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (stationContextsRef.current.search.query === query) {
|
|
617
|
+
setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
if (searchPageRequestRef.current === requestKey) {
|
|
622
|
+
searchPageRequestRef.current = null;
|
|
623
|
+
setLoadingStations(false);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}, [filters, providers, setStationContextFor]);
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
if (screen === 'search' &&
|
|
629
|
+
stationContexts.search.hasMore &&
|
|
630
|
+
stationContexts.search.stations.length - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
|
|
631
|
+
void loadMoreSearchResults();
|
|
632
|
+
}
|
|
633
|
+
}, [
|
|
634
|
+
loadMoreSearchResults,
|
|
635
|
+
screen,
|
|
636
|
+
selected,
|
|
637
|
+
stationContexts.search.hasMore,
|
|
638
|
+
stationContexts.search.stations.length
|
|
639
|
+
]);
|
|
475
640
|
const recallSearchHistory = useCallback((direction) => {
|
|
476
641
|
const history = store.snapshot().searchHistory;
|
|
477
642
|
if (history.length === 0) {
|
|
@@ -500,6 +665,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
500
665
|
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
501
666
|
try {
|
|
502
667
|
if (!settingsRef.current.enableNearbyLocation) {
|
|
668
|
+
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
669
|
+
setMessage('Nearby location lookup is off. Showing the last nearby station list.');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
503
672
|
setStationContextFor('nearby', {
|
|
504
673
|
title: 'Nearby',
|
|
505
674
|
subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
|
|
@@ -510,6 +679,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
510
679
|
const detected = location ?? (await providers.detectLocation());
|
|
511
680
|
setLocation(detected);
|
|
512
681
|
if (!detected) {
|
|
682
|
+
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
683
|
+
setMessage('Location detection is unavailable. Showing the last nearby station list.');
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
513
686
|
setStationContextFor('nearby', {
|
|
514
687
|
title: 'Nearby',
|
|
515
688
|
subtitle: 'Location detection was unavailable',
|
|
@@ -563,7 +736,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
563
736
|
}, []);
|
|
564
737
|
const playStation = useCallback(async (station, options = {}) => {
|
|
565
738
|
const queue = options.queue ?? queueFromCurrentList(station);
|
|
566
|
-
setMessage(`Tuning ${station.name}...`);
|
|
567
739
|
setNowPlaying(null);
|
|
568
740
|
try {
|
|
569
741
|
const resolved = await providers.resolve(station);
|
|
@@ -617,6 +789,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
617
789
|
}
|
|
618
790
|
}, [playStation, store]);
|
|
619
791
|
playStationRef.current = playStation;
|
|
792
|
+
const showTransientFooterMessage = useCallback((nextMessage) => {
|
|
793
|
+
if (transientFooterMessageTimerRef.current) {
|
|
794
|
+
clearTimeout(transientFooterMessageTimerRef.current);
|
|
795
|
+
}
|
|
796
|
+
setFooterMessage(nextMessage);
|
|
797
|
+
transientFooterMessageTimerRef.current = setTimeout(() => {
|
|
798
|
+
setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
799
|
+
transientFooterMessageTimerRef.current = null;
|
|
800
|
+
}, VISUALIZER_MESSAGE_MS);
|
|
801
|
+
}, []);
|
|
620
802
|
const toggleFavorite = useCallback((station) => {
|
|
621
803
|
if (!station) {
|
|
622
804
|
setMessage('Select or play a station before pressing f.');
|
|
@@ -624,12 +806,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
624
806
|
}
|
|
625
807
|
const wasFavorite = store.isFavorite(station);
|
|
626
808
|
setLibrary(store.toggleFavorite(station));
|
|
627
|
-
|
|
809
|
+
const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
|
|
810
|
+
if (screenRef.current === 'library') {
|
|
811
|
+
showTransientFooterMessage(favoriteMessage);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
setMessage(favoriteMessage);
|
|
815
|
+
}
|
|
628
816
|
if (!wasFavorite) {
|
|
629
817
|
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
630
818
|
void providers.vote(station);
|
|
631
819
|
}
|
|
632
|
-
}, [providers, store]);
|
|
820
|
+
}, [providers, showTransientFooterMessage, store]);
|
|
633
821
|
const showControlResult = useCallback((result) => {
|
|
634
822
|
if (!result.ok && result.message) {
|
|
635
823
|
setMessage(result.message);
|
|
@@ -868,6 +1056,55 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
868
1056
|
openScreen(next.screen);
|
|
869
1057
|
}
|
|
870
1058
|
}, [openScreen, screen]);
|
|
1059
|
+
const handleUpdateCommand = useCallback(async () => {
|
|
1060
|
+
const updateCheck = library.updateCheck;
|
|
1061
|
+
if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
|
|
1062
|
+
setMessage('Checking for updates...');
|
|
1063
|
+
const latest = await refreshUpdateCheck();
|
|
1064
|
+
if (latest.error) {
|
|
1065
|
+
setMessage(`Update check failed: ${latest.error}`);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const command = updateCommandForInstall();
|
|
1070
|
+
const copied = copyToClipboard(command.command);
|
|
1071
|
+
const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
|
|
1072
|
+
const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
|
|
1073
|
+
const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
|
|
1074
|
+
setMessage(`${prefix}${copied ? 'Copied' : 'Run'} ${method} update: ${command.command}`);
|
|
1075
|
+
}, [library.updateCheck, refreshUpdateCheck, store]);
|
|
1076
|
+
const updateFromSettings = useCallback(async () => {
|
|
1077
|
+
if (installingUpdateRef.current) {
|
|
1078
|
+
setMessage('Update install already running.');
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
|
|
1082
|
+
if (!currentUpdateCheck?.updateAvailable) {
|
|
1083
|
+
setMessage('Checking for updates...');
|
|
1084
|
+
const latest = await refreshUpdateCheck();
|
|
1085
|
+
if (latest.error) {
|
|
1086
|
+
setMessage(`Update check failed: ${latest.error}`);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (latest.updateAvailable && latest.latestVersion) {
|
|
1090
|
+
setMessage(`Update available: v${latest.latestVersion}. Press Enter on Install update.`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
setMessage(`RadioCLI is up to date at v${installedVersion}.`);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const command = updateCommandForInstall();
|
|
1097
|
+
installingUpdateRef.current = true;
|
|
1098
|
+
setMessage(`Installing update with ${command.method === 'homebrew' ? 'Homebrew' : 'npm'}...`);
|
|
1099
|
+
const result = await installUpdate(command.command);
|
|
1100
|
+
installingUpdateRef.current = false;
|
|
1101
|
+
if (result.ok) {
|
|
1102
|
+
setMessage(`Update installed. Restart RadioCLI to use the new version.`);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
|
|
1106
|
+
setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
|
|
1107
|
+
}, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
|
|
871
1108
|
const executeCommand = useCommandExecutor({
|
|
872
1109
|
beginLearningTransportKey,
|
|
873
1110
|
countries,
|
|
@@ -893,6 +1130,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
893
1130
|
store,
|
|
894
1131
|
toggleFavorite,
|
|
895
1132
|
toggleMute,
|
|
1133
|
+
updateCommand: handleUpdateCommand,
|
|
896
1134
|
updateSettings
|
|
897
1135
|
});
|
|
898
1136
|
const playAdjacent = useCallback((direction) => {
|
|
@@ -985,7 +1223,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
985
1223
|
toggleSetting,
|
|
986
1224
|
toggleNearbyLocation,
|
|
987
1225
|
toggleRadioGarden,
|
|
988
|
-
toggleSkipBrokenStreams
|
|
1226
|
+
toggleSkipBrokenStreams,
|
|
1227
|
+
updateFromSettings
|
|
989
1228
|
});
|
|
990
1229
|
function currentItemCount(currentScreen) {
|
|
991
1230
|
return itemCountsRef.current[currentScreen] ?? 0;
|
|
@@ -1016,7 +1255,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1016
1255
|
playbackBackend: playback.backend,
|
|
1017
1256
|
screen
|
|
1018
1257
|
});
|
|
1019
|
-
return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, 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: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), _jsx(Box, { height: 1, children: message ? _jsx(Text, { color: themeAccent(theme), children: truncate(message, frameWidth) }) : null })] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.
|
|
1258
|
+
return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(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: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck }), _jsx(Box, { height: 1, children: message ? _jsx(Text, { color: themeAccent(theme), children: truncate(message, frameWidth) }) : null })] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(Text, { color: themeAccent(theme), children: footerMessage ? truncate(footerMessage, frameWidth) : ' ' }), playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _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] })] })] })] }) }));
|
|
1020
1259
|
}
|
|
1021
1260
|
function buildLibraryStations(library) {
|
|
1022
1261
|
const stations = [];
|
|
@@ -1047,6 +1286,31 @@ function exploreCursorLocation(cursor) {
|
|
|
1047
1286
|
source: 'explore cursor'
|
|
1048
1287
|
};
|
|
1049
1288
|
}
|
|
1289
|
+
function appendUniqueStations(current, page) {
|
|
1290
|
+
const stations = [...current];
|
|
1291
|
+
const seen = new Set(current.map(stationKey));
|
|
1292
|
+
for (const station of page) {
|
|
1293
|
+
const key = stationKey(station);
|
|
1294
|
+
if (!seen.has(key)) {
|
|
1295
|
+
seen.add(key);
|
|
1296
|
+
stations.push(station);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return stations;
|
|
1300
|
+
}
|
|
1301
|
+
function formatCountryStationsSubtitle(country, loaded, hasMore) {
|
|
1302
|
+
const total = country.stationCount.toLocaleString();
|
|
1303
|
+
const loadedLabel = loaded.toLocaleString();
|
|
1304
|
+
return hasMore
|
|
1305
|
+
? `${country.code} · ${loadedLabel} of ${total} listed stations loaded`
|
|
1306
|
+
: `${country.code} · ${loadedLabel} of ${total} listed stations`;
|
|
1307
|
+
}
|
|
1308
|
+
function formatSearchSubtitle(loaded, hasMore) {
|
|
1309
|
+
const count = loaded.toLocaleString();
|
|
1310
|
+
return hasMore
|
|
1311
|
+
? `Matches across enabled public station directories · ${count}+ loaded`
|
|
1312
|
+
: `Matches across enabled public station directories · ${count} loaded`;
|
|
1313
|
+
}
|
|
1050
1314
|
function formatExploreSubtitle(cursor, stations) {
|
|
1051
1315
|
if (stations.length === 0) {
|
|
1052
1316
|
return `No geotagged stations near ${formatExploreCursor(cursor)}`;
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -15,7 +15,7 @@ import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
|
|
|
15
15
|
import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
|
|
16
16
|
import { selectedAirPlayDevice } from './airplay-settings.js';
|
|
17
17
|
import { playbackBackendLabel } from '../player/backend-install.js';
|
|
18
|
-
export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme }) {
|
|
18
|
+
export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck }) {
|
|
19
19
|
if (layout.compact) {
|
|
20
20
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: textMuted, children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: textMuted, children: ["Playback: ", playback.state, " \u00B7 ", playbackBackendLabel(playback.backend)] }), _jsx(Text, { color: textMuted, children: "q quit \u00B7 Ctrl+C always exits" })] }));
|
|
21
21
|
}
|
|
@@ -44,7 +44,7 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
|
|
|
44
44
|
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
45
45
|
}
|
|
46
46
|
if (screen === 'settings') {
|
|
47
|
-
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
|
|
47
|
+
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
|
|
48
48
|
}
|
|
49
49
|
if (screen === 'airplay-settings') {
|
|
50
50
|
return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth }));
|
package/dist/ui/app-state.js
CHANGED
|
@@ -173,6 +173,9 @@ export function stationContextKeyForScreen(screen) {
|
|
|
173
173
|
export function shouldAnimateReceiver(screen, playback) {
|
|
174
174
|
return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
|
|
175
175
|
}
|
|
176
|
+
export function shouldToggleNearbyLocationShortcut(input, screen) {
|
|
177
|
+
return input === 'l' && (screen === 'home' || screen === 'nearby' || screen === 'settings');
|
|
178
|
+
}
|
|
176
179
|
export function nextReceiverPulse(pulse) {
|
|
177
180
|
return pulse + 1;
|
|
178
181
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
export function Menu({ items, selected, keyFor, render }) {
|
|
4
|
-
|
|
4
|
+
const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
|
|
5
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
|
|
5
6
|
}
|
|
6
7
|
export function Pointer({ active }) {
|
|
7
8
|
return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
|
|
@@ -12,12 +12,12 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
|
|
|
12
12
|
const rowWidth = Math.max(42, width - 4);
|
|
13
13
|
const nameWidth = Math.min(48, Math.max(18, Math.floor(rowWidth * 0.42)));
|
|
14
14
|
const metaWidth = Math.max(12, rowWidth - nameWidth - 6);
|
|
15
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", stations.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station,
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", stations.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station, _index, active) => {
|
|
16
16
|
const favorite = favorites.has(`${station.provider}:${station.id}`);
|
|
17
17
|
const stationName = truncate(station.name, favorite ? Math.max(1, nameWidth - 2) : nameWidth);
|
|
18
18
|
const titleWidth = nameWidth + 2;
|
|
19
19
|
const titleUsed = stationName.length + (favorite ? 2 : 0);
|
|
20
20
|
const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: textMuted, children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children:
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: textMuted, children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children: _jsx(Text, { color: textMuted, children: truncate(stationTags(station), rowWidth - 4) }) })) : null] }));
|
|
22
22
|
} })] }));
|
|
23
23
|
}
|
|
@@ -5,7 +5,7 @@ import { useDisplay } from '../display-context.js';
|
|
|
5
5
|
import { truncate } from '../format.js';
|
|
6
6
|
export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
7
7
|
const accent = themeAccent(theme);
|
|
8
|
-
const {
|
|
8
|
+
const { app: background, ascii } = useDisplay();
|
|
9
9
|
const box = ascii
|
|
10
10
|
? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
|
|
11
11
|
: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
|
|
@@ -20,7 +20,7 @@ export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
|
20
20
|
const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
|
|
21
21
|
const visibleTabsWidth = tabsWidth(visibleTabs);
|
|
22
22
|
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
|
|
23
|
-
return (_jsxs(Box, { flexDirection: "column", backgroundColor:
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
|
|
24
24
|
}
|
|
25
25
|
function fitTabs(tabs, active, maxWidth) {
|
|
26
26
|
const all = tabs.map(tab => ({ type: 'tab', tab }));
|
package/dist/ui/help-content.js
CHANGED
|
@@ -66,6 +66,7 @@ export const commandHelp = [
|
|
|
66
66
|
{ name: 'map', description: 'Open the world map' },
|
|
67
67
|
{ name: 'stats', description: 'Open listening stats' },
|
|
68
68
|
{ name: 'settings', description: 'Open settings' },
|
|
69
|
+
{ name: 'update', description: 'Show the install command for the latest release' },
|
|
69
70
|
{ name: 'stop', description: 'Stop playback' },
|
|
70
71
|
{ name: 'help', description: 'Open this help' }
|
|
71
72
|
];
|
package/dist/ui/layout.js
CHANGED
|
@@ -7,6 +7,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
|
|
|
7
7
|
const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
|
|
8
8
|
const mapMode = safeColumns >= 88 && contentRows >= 24 ? 'full' : 'compact';
|
|
9
9
|
const stationRows = clamp(contentRows - 6, 1, 48);
|
|
10
|
+
const countryRows = clamp(contentRows - 5, 1, 64);
|
|
10
11
|
return {
|
|
11
12
|
columns: safeColumns,
|
|
12
13
|
rows: safeRows,
|
|
@@ -14,7 +15,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
|
|
|
14
15
|
topRows,
|
|
15
16
|
contentRows,
|
|
16
17
|
stationRows: compact ? 0 : stationRows,
|
|
17
|
-
countryRows: compact ? 0 :
|
|
18
|
+
countryRows: compact ? 0 : countryRows,
|
|
18
19
|
mapCountryRows: compact ? 0 : Math.max(1, contentRows - (mapMode === 'full' ? 25 : 14)),
|
|
19
20
|
mapMode,
|
|
20
21
|
receiverWidth: compact ? safeColumns : Math.max(62, safeColumns - 4),
|
package/dist/ui/page-footer.js
CHANGED
|
@@ -7,7 +7,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
7
7
|
return `COMMAND :${commandText}`;
|
|
8
8
|
}
|
|
9
9
|
if (screen === 'home') {
|
|
10
|
-
return '↑/↓ move · Enter open · number jump · : command';
|
|
10
|
+
return '↑/↓ move · Enter open · number jump · l location · : command';
|
|
11
11
|
}
|
|
12
12
|
if (screen === 'search' && editingSearch) {
|
|
13
13
|
return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
|
|
@@ -27,9 +27,10 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
27
27
|
if (screen === 'explore') {
|
|
28
28
|
return 'Click map · WASD fine move · Shift+WASD jump · ↑/↓ station · Enter tune · f favorite · b home';
|
|
29
29
|
}
|
|
30
|
-
if (screen === 'nearby'
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (screen === 'nearby') {
|
|
31
|
+
return '↑/↓ or n/p move · Enter tune · f favorite · l location · [/] page · b home';
|
|
32
|
+
}
|
|
33
|
+
if (screen === 'stations' || screen === 'library') {
|
|
33
34
|
return '↑/↓ or n/p move · Enter tune · f favorite · [/] page · b home';
|
|
34
35
|
}
|
|
35
36
|
if (screen === 'now-playing') {
|
|
@@ -16,7 +16,9 @@ export function shouldShowPlaybackFooter(station, playback) {
|
|
|
16
16
|
return Boolean((station || playback.stationName) && visiblePlaybackStates.has(playback.state));
|
|
17
17
|
}
|
|
18
18
|
export function playbackFooterText({ station, playback, metadata, sleepLabel, width, spinnerFrame }) {
|
|
19
|
-
const stationName =
|
|
19
|
+
const stationName = playback.state === 'loading'
|
|
20
|
+
? playback.stationName ?? station?.name
|
|
21
|
+
: station?.name ?? playback.stationName;
|
|
20
22
|
if (!stationName || !visiblePlaybackStates.has(playback.state)) {
|
|
21
23
|
return null;
|
|
22
24
|
}
|