@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 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
 
@@ -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(false),
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: false,
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: false,
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, showPlaybackFooter ? 3 : 2);
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
- setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
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.panel, children: [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) })] })] }) }));
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 = [];
@@ -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
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === selected) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
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 { panel: panelBackground, ascii } = useDisplay();
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: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, 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: panelBackground, 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: panelBackground, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
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 : Math.max(1, contentRows - 4),
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),
@@ -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
- screen === 'stations' ||
32
- screen === 'library') {
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
- 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) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.name }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", country.code, " \u00B7 ", country.stationCount.toLocaleString(), " stations"] })] })) })] })) : null] }));
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: '', gap: '' }
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
- const tileHeight = graph.cellText.trim().length === 0 ? 2 : 1;
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 renderContributionCell(cell, graphColors, cellGap) {
35
- if (!cell.visible) {
36
- return _jsx(Text, { children: cell.text }, cell.key);
37
- }
38
- const color = graphColors[cell.level] ?? graphColors[0] ?? textMuted;
39
- if (cell.text.trim().length === 0) {
40
- return (_jsxs(React.Fragment, { children: [_jsx(Text, { backgroundColor: color, children: cell.text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }, cell.key));
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 (_jsxs(React.Fragment, { children: [_jsx(Text, { color: color, children: cell.text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }, cell.key));
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 = [
@@ -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 === 'l') {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",