@ciphore/radiocli 0.1.8 → 0.1.9
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 +22 -0
- package/README.md +1 -1
- package/dist/storage/store.js +4 -5
- package/dist/ui/App.js +37 -4
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/components/Menu.js +2 -1
- package/dist/ui/components/TopTabs.js +2 -2
- package/dist/ui/layout.js +2 -1
- package/dist/ui/page-footer.js +5 -4
- package/dist/ui/screens/CountriesScreen.js +7 -1
- package/dist/ui/screens/StatsScreen.js +34 -16
- package/dist/ui/screens/screen-render.test.js +32 -1
- package/dist/ui/use-app-input.js +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.9] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Nearby station discovery is now enabled by default for new libraries, and the
|
|
15
|
+
`l` shortcut is limited to Overview, Nearby, and Settings so country lists
|
|
16
|
+
keep normal letter navigation.
|
|
17
|
+
- Favorite changes from Library now appear in the footer message row instead of
|
|
18
|
+
displacing the main page status.
|
|
19
|
+
- The listening stats activity graph now uses solid truecolor cell backgrounds
|
|
20
|
+
with tighter rendering on short terminals and fewer split color runs.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Nearby keeps showing the last loaded station list when location lookup is
|
|
25
|
+
turned off or temporarily unavailable.
|
|
26
|
+
- Long country names are truncated to one terminal row so dense country lists do
|
|
27
|
+
not wrap into nearby entries.
|
|
28
|
+
- Pressing `b` from a country station list now returns to Countries before
|
|
29
|
+
falling back to Overview.
|
|
30
|
+
|
|
10
31
|
## [0.1.8] - 2026-07-02
|
|
11
32
|
|
|
12
33
|
### Changed
|
|
@@ -178,6 +199,7 @@ Initial public release.
|
|
|
178
199
|
[0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
|
|
179
200
|
[0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
|
|
180
201
|
[0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
|
|
202
|
+
[0.1.9]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.9
|
|
181
203
|
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
182
204
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
183
205
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ Live public radio from around the world
|
|
|
76
76
|
|
|
77
77
|
3 recent · 2 favorites · 1 imported
|
|
78
78
|
|
|
79
|
-
↑/↓ move · Enter open · number jump · : command
|
|
79
|
+
↑/↓ move · Enter open · number jump · l location · : command
|
|
80
80
|
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
|
|
81
81
|
```
|
|
82
82
|
|
package/dist/storage/store.js
CHANGED
|
@@ -28,8 +28,7 @@ const stationSchema = z
|
|
|
28
28
|
distanceKm: z.number().optional(),
|
|
29
29
|
hls: z.boolean().optional(),
|
|
30
30
|
lastCheckedOk: z.boolean().optional()
|
|
31
|
-
})
|
|
32
|
-
.strict();
|
|
31
|
+
});
|
|
33
32
|
const defaultMediaKeys = {
|
|
34
33
|
previous: [],
|
|
35
34
|
playPause: [],
|
|
@@ -138,7 +137,7 @@ const settingsSchema = z.object({
|
|
|
138
137
|
receiverStyleVersion: z.number().optional(),
|
|
139
138
|
volume: z.number().min(0).max(100).default(70),
|
|
140
139
|
enableRadioGarden: z.boolean().default(false),
|
|
141
|
-
enableNearbyLocation: z.boolean().default(
|
|
140
|
+
enableNearbyLocation: z.boolean().default(true),
|
|
142
141
|
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
|
|
143
142
|
preferredAirPlayDevice: z.string().min(1).optional(),
|
|
144
143
|
tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
|
|
@@ -192,7 +191,7 @@ const librarySchema = z.object({
|
|
|
192
191
|
receiverStyleVersion: 2,
|
|
193
192
|
volume: 70,
|
|
194
193
|
enableRadioGarden: false,
|
|
195
|
-
enableNearbyLocation:
|
|
194
|
+
enableNearbyLocation: true,
|
|
196
195
|
preferredBackend: 'auto',
|
|
197
196
|
tuneTimeoutSeconds: 12,
|
|
198
197
|
skipBrokenStreams: true,
|
|
@@ -387,7 +386,7 @@ function defaultState() {
|
|
|
387
386
|
receiverStyleVersion: 2,
|
|
388
387
|
volume: 70,
|
|
389
388
|
enableRadioGarden: false,
|
|
390
|
-
enableNearbyLocation:
|
|
389
|
+
enableNearbyLocation: true,
|
|
391
390
|
preferredBackend: 'auto',
|
|
392
391
|
tuneTimeoutSeconds: 12,
|
|
393
392
|
skipBrokenStreams: true,
|
package/dist/ui/App.js
CHANGED
|
@@ -51,6 +51,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
51
51
|
const [screen, setScreen] = useState('home');
|
|
52
52
|
const [selected, setSelected] = useState(0);
|
|
53
53
|
const [message, setMessage] = useState(null);
|
|
54
|
+
const [footerMessage, setFooterMessage] = useState(null);
|
|
54
55
|
const [countries, setCountries] = useState([]);
|
|
55
56
|
const [countryFilter, setCountryFilter] = useState('');
|
|
56
57
|
const [editingCountryFilter, setEditingCountryFilter] = useState(false);
|
|
@@ -88,6 +89,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
88
89
|
const exploreRequestRef = useRef(0);
|
|
89
90
|
const exploreMoveTimerRef = useRef(null);
|
|
90
91
|
const transientMessageTimerRef = useRef(null);
|
|
92
|
+
const transientFooterMessageTimerRef = useRef(null);
|
|
91
93
|
const receiverPulseSnapshotRef = useRef(null);
|
|
92
94
|
const theme = library.settings.theme;
|
|
93
95
|
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
@@ -160,10 +162,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
160
162
|
displayStationsRef.current = displayStations;
|
|
161
163
|
const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
|
|
162
164
|
const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
|
|
165
|
+
const footerRows = showPlaybackFooter ? 4 : 3;
|
|
163
166
|
const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
|
|
164
167
|
const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
|
|
165
168
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
166
|
-
const layout = computeTerminalLayout(columns, rows,
|
|
169
|
+
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
167
170
|
const frameWidth = Math.max(40, layout.columns - 2);
|
|
168
171
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
169
172
|
const playingStationRef = useRef(null);
|
|
@@ -241,6 +244,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
241
244
|
if (transientMessageTimerRef.current) {
|
|
242
245
|
clearTimeout(transientMessageTimerRef.current);
|
|
243
246
|
}
|
|
247
|
+
if (transientFooterMessageTimerRef.current) {
|
|
248
|
+
clearTimeout(transientFooterMessageTimerRef.current);
|
|
249
|
+
}
|
|
244
250
|
}, []);
|
|
245
251
|
useEffect(() => {
|
|
246
252
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
@@ -321,6 +327,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
321
327
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
322
328
|
const remembered = selectedByScreenRef.current[next] ?? 0;
|
|
323
329
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
330
|
+
if (next === 'now-playing' && screenRef.current !== 'now-playing') {
|
|
331
|
+
setPulse(0);
|
|
332
|
+
}
|
|
324
333
|
setScreen(next);
|
|
325
334
|
setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
|
|
326
335
|
if (options.clearMessage !== false) {
|
|
@@ -500,6 +509,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
500
509
|
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
501
510
|
try {
|
|
502
511
|
if (!settingsRef.current.enableNearbyLocation) {
|
|
512
|
+
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
513
|
+
setMessage('Nearby location lookup is off. Showing the last nearby station list.');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
503
516
|
setStationContextFor('nearby', {
|
|
504
517
|
title: 'Nearby',
|
|
505
518
|
subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
|
|
@@ -510,6 +523,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
510
523
|
const detected = location ?? (await providers.detectLocation());
|
|
511
524
|
setLocation(detected);
|
|
512
525
|
if (!detected) {
|
|
526
|
+
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
527
|
+
setMessage('Location detection is unavailable. Showing the last nearby station list.');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
513
530
|
setStationContextFor('nearby', {
|
|
514
531
|
title: 'Nearby',
|
|
515
532
|
subtitle: 'Location detection was unavailable',
|
|
@@ -617,6 +634,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
617
634
|
}
|
|
618
635
|
}, [playStation, store]);
|
|
619
636
|
playStationRef.current = playStation;
|
|
637
|
+
const showTransientFooterMessage = useCallback((nextMessage) => {
|
|
638
|
+
if (transientFooterMessageTimerRef.current) {
|
|
639
|
+
clearTimeout(transientFooterMessageTimerRef.current);
|
|
640
|
+
}
|
|
641
|
+
setFooterMessage(nextMessage);
|
|
642
|
+
transientFooterMessageTimerRef.current = setTimeout(() => {
|
|
643
|
+
setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
644
|
+
transientFooterMessageTimerRef.current = null;
|
|
645
|
+
}, VISUALIZER_MESSAGE_MS);
|
|
646
|
+
}, []);
|
|
620
647
|
const toggleFavorite = useCallback((station) => {
|
|
621
648
|
if (!station) {
|
|
622
649
|
setMessage('Select or play a station before pressing f.');
|
|
@@ -624,12 +651,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
624
651
|
}
|
|
625
652
|
const wasFavorite = store.isFavorite(station);
|
|
626
653
|
setLibrary(store.toggleFavorite(station));
|
|
627
|
-
|
|
654
|
+
const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
|
|
655
|
+
if (screenRef.current === 'library') {
|
|
656
|
+
showTransientFooterMessage(favoriteMessage);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
setMessage(favoriteMessage);
|
|
660
|
+
}
|
|
628
661
|
if (!wasFavorite) {
|
|
629
662
|
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
630
663
|
void providers.vote(station);
|
|
631
664
|
}
|
|
632
|
-
}, [providers, store]);
|
|
665
|
+
}, [providers, showTransientFooterMessage, store]);
|
|
633
666
|
const showControlResult = useCallback((result) => {
|
|
634
667
|
if (!result.ok && result.message) {
|
|
635
668
|
setMessage(result.message);
|
|
@@ -1016,7 +1049,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
1016
1049
|
playbackBackend: playback.backend,
|
|
1017
1050
|
screen
|
|
1018
1051
|
});
|
|
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.
|
|
1052
|
+
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.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) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }) }));
|
|
1020
1053
|
}
|
|
1021
1054
|
function buildLibraryStations(library) {
|
|
1022
1055
|
const stations = [];
|
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 ? '> ' : ' ' });
|
|
@@ -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/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') {
|
|
@@ -4,7 +4,13 @@ import { Menu, Pointer } from '../components/Menu.js';
|
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
5
|
import { textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
|
+
import { truncate } from '../format.js';
|
|
7
8
|
export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
|
|
8
9
|
const window = visibleWindow(countries, selected, pageSize);
|
|
9
|
-
|
|
10
|
+
const rowWidth = Math.max(24, width - 2);
|
|
11
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Countries", subtitle: editingFilter ? 'Filtering countries — type to narrow the list' : 'Browse the worldwide country directory', width: width, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: textMuted, children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", countries.length ? window.start + 1 : 0, "-", window.end, " of ", countries.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => {
|
|
12
|
+
const meta = ` · ${country.code} · ${country.stationCount.toLocaleString()} stations`;
|
|
13
|
+
const nameWidth = Math.max(4, rowWidth - 2 - meta.length);
|
|
14
|
+
return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: truncate(country.name, nameWidth) }), _jsx(Text, { color: textMuted, children: truncate(meta, Math.max(0, rowWidth - 2 - nameWidth)) })] }));
|
|
15
|
+
} })] })) : null] }));
|
|
10
16
|
}
|
|
@@ -9,37 +9,54 @@ import { truncate } from '../format.js';
|
|
|
9
9
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
10
10
|
const dayLabelWidth = 5;
|
|
11
11
|
const heatmapCellOptions = [
|
|
12
|
-
{ cell: ' ', gap: ' ' },
|
|
13
12
|
{ cell: ' ', gap: '' },
|
|
14
|
-
{ cell: '
|
|
13
|
+
{ cell: ' ', gap: '' }
|
|
15
14
|
];
|
|
16
|
-
const compactHeatmapCell = '
|
|
15
|
+
const compactHeatmapCell = ' ';
|
|
17
16
|
const compactHeatmapGap = '';
|
|
17
|
+
const tallHeatmapMinHeight = 26;
|
|
18
18
|
export function StatsScreen({ library, theme, width, height }) {
|
|
19
19
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
20
20
|
const stats = computeListeningStats(library.activity.sessions);
|
|
21
21
|
const contentWidth = Math.max(20, width - 4);
|
|
22
|
-
const graph = buildContributionGraph(stats.days, contentWidth);
|
|
22
|
+
const graph = buildContributionGraph(stats.days, contentWidth, height);
|
|
23
23
|
const graphColors = themeContributionColors(theme);
|
|
24
24
|
const favorite = stats.favoriteStation?.name ?? 'none yet';
|
|
25
25
|
const totalHours = stats.totalSeconds / 3600;
|
|
26
26
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
27
27
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
28
28
|
const compact = height < 30;
|
|
29
|
-
|
|
30
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: themeAccent(theme), bold: true, children: ["Activity \u2014 ", graph.year] }) }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), row.cells.map(cell => renderContributionCell(cell, graphColors, graph.cellGap))] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: themeAccent(theme), bold: true, children: ["Activity \u2014 ", graph.year] }) }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
|
|
31
30
|
? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
|
|
32
31
|
: 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
|
|
33
32
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
function renderContributionCells(cells, graphColors, cellGap) {
|
|
34
|
+
const runs = [];
|
|
35
|
+
for (const cell of cells) {
|
|
36
|
+
const text = cellGap ? `${cell.text}${cell.visible ? cellGap : ''}` : cell.text;
|
|
37
|
+
const previous = runs[runs.length - 1];
|
|
38
|
+
if (previous && previous.visible === cell.visible && previous.level === cell.level) {
|
|
39
|
+
previous.text += text;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
runs.push({
|
|
43
|
+
key: `${runs.length}-${cell.key}`,
|
|
44
|
+
level: cell.level,
|
|
45
|
+
text,
|
|
46
|
+
visible: cell.visible
|
|
47
|
+
});
|
|
48
|
+
}
|
|
41
49
|
}
|
|
42
|
-
return
|
|
50
|
+
return runs.map(run => {
|
|
51
|
+
if (!run.visible) {
|
|
52
|
+
return _jsx(Text, { children: run.text }, run.key);
|
|
53
|
+
}
|
|
54
|
+
const color = graphColors[run.level] ?? graphColors[0] ?? textMuted;
|
|
55
|
+
if (run.text.trim().length === 0) {
|
|
56
|
+
return _jsx(Text, { backgroundColor: color, children: run.text }, run.key);
|
|
57
|
+
}
|
|
58
|
+
return _jsx(Text, { color: color, children: run.text }, run.key);
|
|
59
|
+
});
|
|
43
60
|
}
|
|
44
61
|
function renderLegendCell(color, text, cellGap) {
|
|
45
62
|
if (text.trim().length === 0) {
|
|
@@ -52,7 +69,7 @@ function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, t
|
|
|
52
69
|
const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
|
|
53
70
|
return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: textMuted, children: [leftLabel, ": "] }), _jsx(Text, { color: accent, children: leftValue })] }) }), _jsx(Text, { children: ' '.repeat(leftPadding > 2 ? 2 : leftPadding) }), _jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: textMuted, children: [rightLabel, ": "] }), _jsx(Text, { color: accent, children: rightValue })] }) })] }));
|
|
54
71
|
}
|
|
55
|
-
export function buildContributionGraph(days, width) {
|
|
72
|
+
export function buildContributionGraph(days, width, height = tallHeatmapMinHeight) {
|
|
56
73
|
const year = graphYear(days);
|
|
57
74
|
const weeks = calendarYearWeeks(year);
|
|
58
75
|
const largestCell = heatmapCellOptions.find(option => dayLabelWidth + weeks.length * (option.cell.length + option.gap.length) <= width);
|
|
@@ -62,6 +79,7 @@ export function buildContributionGraph(days, width) {
|
|
|
62
79
|
const cellWidth = largeCellWidth > 0 ? largeCellWidth : compactCellWidth;
|
|
63
80
|
const cellText = selectedCell.cell;
|
|
64
81
|
const cellGap = selectedCell.gap;
|
|
82
|
+
const tileHeight = cellText.trim().length === 0 && height >= tallHeatmapMinHeight ? 2 : 1;
|
|
65
83
|
const secondsByDate = new Map(days.map(day => [day.date, day.seconds]));
|
|
66
84
|
const yearDays = days.filter(day => parseLocalDay(day.date).getFullYear() === year);
|
|
67
85
|
const scaleSeconds = contributionScaleSeconds(yearDays);
|
|
@@ -84,7 +102,7 @@ export function buildContributionGraph(days, width) {
|
|
|
84
102
|
return { key: date, level, text: cellText, visible: true };
|
|
85
103
|
})
|
|
86
104
|
}));
|
|
87
|
-
return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, rows };
|
|
105
|
+
return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, tileHeight, rows };
|
|
88
106
|
}
|
|
89
107
|
export function contributionScaleSeconds(days) {
|
|
90
108
|
const activeSeconds = days
|
|
@@ -6,6 +6,7 @@ import { HelpScreen } from './HelpScreen.js';
|
|
|
6
6
|
import { NowPlayingScreen } from './NowPlayingScreen.js';
|
|
7
7
|
import { SettingsScreen } from './SettingsScreen.js';
|
|
8
8
|
import { ExploreScreen } from './ExploreScreen.js';
|
|
9
|
+
import { CountriesScreen } from './CountriesScreen.js';
|
|
9
10
|
import { buildContributionGraph, contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
|
|
10
11
|
import { settingsItems } from '../screen-items.js';
|
|
11
12
|
import { defaultExploreCursor } from '../app-state.js';
|
|
@@ -82,6 +83,20 @@ describe('Explore world map rendering', () => {
|
|
|
82
83
|
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
83
84
|
});
|
|
84
85
|
});
|
|
86
|
+
describe('CountriesScreen rendering', () => {
|
|
87
|
+
it('keeps long country rows to one terminal line', () => {
|
|
88
|
+
const frame = render(_jsx(CountriesScreen, { countries: [
|
|
89
|
+
{
|
|
90
|
+
name: 'The Extremely Long Democratic Republic Of The Country With A Very Long Name',
|
|
91
|
+
code: 'TL',
|
|
92
|
+
stationCount: 123456789
|
|
93
|
+
}
|
|
94
|
+
], selected: 0, loading: false, filter: "", editingFilter: false, theme: "green", pageSize: 1, width: 48 })).lastFrame() ?? '';
|
|
95
|
+
expect(frame).toContain('>');
|
|
96
|
+
expect(frame).toContain('…');
|
|
97
|
+
expect(frame).not.toContain('Very Long Name');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
85
100
|
describe('StatsScreen rendering', () => {
|
|
86
101
|
it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
|
|
87
102
|
const library = {
|
|
@@ -115,7 +130,8 @@ describe('StatsScreen rendering', () => {
|
|
|
115
130
|
expect(graph.months.startsWith('Jan')).toBe(true);
|
|
116
131
|
expect(graph.months).toContain('Dec');
|
|
117
132
|
expect(graph.cellText).toBe(' ');
|
|
118
|
-
expect(graph.cellGap).toBe('
|
|
133
|
+
expect(graph.cellGap).toBe('');
|
|
134
|
+
expect(graph.tileHeight).toBe(2);
|
|
119
135
|
expect(graph.rows[4]?.cells[0]).toMatchObject({
|
|
120
136
|
level: 4,
|
|
121
137
|
text: ' ',
|
|
@@ -137,6 +153,21 @@ describe('StatsScreen rendering', () => {
|
|
|
137
153
|
], 128);
|
|
138
154
|
expect(normalWidthGraph.cellText).toBe(' ');
|
|
139
155
|
expect(normalWidthGraph.cellGap).toBe('');
|
|
156
|
+
const compactWidthGraph = buildContributionGraph([
|
|
157
|
+
{ date: '2026-01-01', seconds: 3600 },
|
|
158
|
+
{ date: '2026-12-31', seconds: 0 }
|
|
159
|
+
], 80);
|
|
160
|
+
expect(compactWidthGraph.cellText).toBe(' ');
|
|
161
|
+
expect(compactWidthGraph.cellGap).toBe('');
|
|
162
|
+
});
|
|
163
|
+
it('uses one-line heatmap cells when the stats panel is height constrained', () => {
|
|
164
|
+
const graph = buildContributionGraph([
|
|
165
|
+
{ date: '2026-01-01', seconds: 3600 },
|
|
166
|
+
{ date: '2026-12-31', seconds: 0 }
|
|
167
|
+
], 170, 20);
|
|
168
|
+
expect(graph.cellText).toBe(' ');
|
|
169
|
+
expect(graph.cellGap).toBe('');
|
|
170
|
+
expect(graph.tileHeight).toBe(1);
|
|
140
171
|
});
|
|
141
172
|
it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
|
|
142
173
|
const days = [
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { useInput } from 'ink';
|
|
3
3
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
4
|
-
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent } from './app-state.js';
|
|
4
|
+
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldToggleNearbyLocationShortcut } from './app-state.js';
|
|
5
5
|
import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
|
|
6
6
|
import { completeCommand } from './help-content.js';
|
|
7
7
|
export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
|
|
@@ -256,7 +256,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
256
256
|
toggleRadioGarden();
|
|
257
257
|
return;
|
|
258
258
|
}
|
|
259
|
-
if (input
|
|
259
|
+
if (shouldToggleNearbyLocationShortcut(input, screen)) {
|
|
260
260
|
toggleNearbyLocation();
|
|
261
261
|
return;
|
|
262
262
|
}
|
|
@@ -303,6 +303,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
305
|
if (input === 'b' || key.escape) {
|
|
306
|
+
if (screen === 'stations') {
|
|
307
|
+
go('countries');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
306
310
|
go('home');
|
|
307
311
|
return;
|
|
308
312
|
}
|