@ciphore/radiocli 0.1.6 → 0.1.8

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,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.8] - 2026-07-02
11
+
12
+ ### Changed
13
+
14
+ - Replaced the classic equalizer receiver style with Ultracode, a centered
15
+ color-ripple display that follows the active display color; saved `equalizer`
16
+ preferences migrate to `ultracode`.
17
+ - The listening stats activity graph now renders as a calendar-year grid with
18
+ larger contribution cells and clearer month labels when the terminal has room.
19
+ - Muted UI text now uses truecolor neutrals instead of terminal `gray`, making
20
+ secondary labels more readable across dark terminal palettes.
21
+
22
+ ### Fixed
23
+
24
+ - Stale continuous listening sessions are capped before splitting them across
25
+ days so one long-unclosed playback session cannot dominate listening totals.
26
+ - Receiver visualizer pulses now reset when an active Ultracode view opens but
27
+ keep advancing smoothly during normal playback.
28
+
29
+ ## [0.1.7] - 2026-06-29
30
+
31
+ ### Changed
32
+
33
+ - Search results can now be navigated with `Up` / `Down` while editing the
34
+ search query; search-history recall remains available with
35
+ `Ctrl+Up` / `Ctrl+Down`.
36
+ - The listening stats activity heatmap now renders day cells as square markers
37
+ and uses a capped contribution scale so one unusually long listening day does
38
+ not flatten the rest of the Less/More color range.
39
+
10
40
  ## [0.1.6] - 2026-06-17
11
41
 
12
42
  ### Added
@@ -146,6 +176,8 @@ Initial public release.
146
176
 
147
177
  [0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
148
178
  [0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
179
+ [0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
180
+ [0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
149
181
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
150
182
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
151
183
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/README.md CHANGED
@@ -82,7 +82,7 @@ Live public radio from around the world
82
82
 
83
83
  The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
84
84
 
85
- - **Classic receiver** — equalizer, LEDs, and goniometer.
85
+ - **Classic receiver** — ultracode ripple, LEDs, and goniometer.
86
86
  - **High-resolution braille** — smooth waveform, radial EQ, spectrogram, nebula, silk, ripple tank, phyllotaxis, harmonograph, bloom bars, moiré, galaxy, caustics.
87
87
  - **Generative & motion** — matrix, hologram, spinning ASCII cube, generated fire, fireworks, plasma, spinning donut, starfield, Lorenz attractor, Barnsley fern, Chladni plate, rotating tesseract, torus knot, fractal tree, Julia sets, and lava-lamp motion.
88
88
 
@@ -279,7 +279,7 @@ Page-specific footer controls:
279
279
  | Screen | Controls |
280
280
  | --- | --- |
281
281
  | Home | `↑` / `↓` move, `Enter` open, number jump, `:` command |
282
- | Search input | type query, `Backspace` edit, `Enter` search or tune, `Esc` finish |
282
+ | Search input | type query, `Backspace` edit, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Esc` finish |
283
283
  | Search results | `/` edit query, `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home |
284
284
  | Explore | click map, `WASD` fine move, `Shift+WASD` jump, `↑` / `↓` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
285
285
  | Countries | `/` filter, `↑` / `↓` move, `Enter` open stations, `w` map, `b` home |
@@ -1,6 +1,7 @@
1
1
  import { stationKey } from '../storage/store.js';
2
2
  const trackedDays = 371;
3
3
  const listenedStationThresholdSeconds = 120;
4
+ const maxContinuousListeningSeconds = 12 * 60 * 60;
4
5
  export function computeListeningStats(sessions, now = new Date()) {
5
6
  const today = startOfLocalDay(now);
6
7
  const firstDay = addLocalDays(today, -(trackedDays - 1));
@@ -49,9 +50,9 @@ function sessionSeconds(session, now = new Date()) {
49
50
  const started = Date.parse(session.startedAt);
50
51
  const ended = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
51
52
  if (!Number.isFinite(started) || !Number.isFinite(ended) || ended <= started) {
52
- return Math.max(0, Math.round(session.listenedSeconds));
53
+ return Math.min(maxContinuousListeningSeconds, Math.max(0, Math.round(session.listenedSeconds)));
53
54
  }
54
- return Math.max(Math.round(session.listenedSeconds), Math.round((ended - started) / 1000));
55
+ return Math.min(maxContinuousListeningSeconds, Math.max(Math.round(session.listenedSeconds), Math.round((ended - started) / 1000)));
55
56
  }
56
57
  function localDay(date) {
57
58
  const year = date.getFullYear();
@@ -68,7 +69,7 @@ function splitSessionByDay(session, seconds, firstDay, lastDayEnd, now) {
68
69
  const rawEnd = Number.isFinite(recordedEnd) && recordedEnd > started
69
70
  ? recordedEnd
70
71
  : started + seconds * 1000;
71
- const end = Math.max(started, rawEnd);
72
+ const end = Math.max(started, Math.min(rawEnd, started + seconds * 1000));
72
73
  const boundedStart = Math.max(started, firstDay.getTime());
73
74
  const boundedEnd = Math.min(end, lastDayEnd.getTime());
74
75
  if (boundedEnd <= boundedStart) {
@@ -127,6 +127,9 @@ const removedReceiverStyles = new Set([
127
127
  'voronoi'
128
128
  ]);
129
129
  function normalizeReceiverStyle(value) {
130
+ if (value === 'equalizer') {
131
+ return 'ultracode';
132
+ }
130
133
  return typeof value === 'string' && removedReceiverStyles.has(value) ? defaultReceiverStyle : value;
131
134
  }
132
135
  const settingsSchema = z.object({
package/dist/types.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
2
2
  export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
3
3
  export const receiverStyleNames = [
4
- 'equalizer',
4
+ 'ultracode',
5
5
  'motion-blob',
6
6
  'motion-area',
7
7
  'motion-contour',
package/dist/ui/App.js CHANGED
@@ -6,7 +6,7 @@ import { PlayerController } from '../player/player-controller.js';
6
6
  import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
7
7
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
8
8
  import { receiverStyleNames } from '../types.js';
9
- import { nextReceiverStyle, nextTheme, textDim, themeAccent } from './theme.js';
9
+ import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
10
10
  import { DisplayContext, resolveDisplayMode } from './display-context.js';
11
11
  import { homeItems, settingsItems } from './screen-items.js';
12
12
  import { AppContent } from './AppContent.js';
@@ -22,11 +22,12 @@ 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 { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
25
+ 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
26
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
27
27
  const LIVE_RECEIVER_PULSE_MS = 80;
28
28
  const AMBIENT_RECEIVER_PULSE_MS = 140;
29
29
  const LOADING_SPINNER_MS = 120;
30
+ const VISUALIZER_MESSAGE_MS = 4500;
30
31
  const settingToggleLabel = {
31
32
  resumeOnLaunch: 'Resume on launch',
32
33
  transparentBackground: 'Transparent background',
@@ -86,6 +87,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
86
87
  const exploreCursorRef = useRef(exploreCursor);
87
88
  const exploreRequestRef = useRef(0);
88
89
  const exploreMoveTimerRef = useRef(null);
90
+ const transientMessageTimerRef = useRef(null);
91
+ const receiverPulseSnapshotRef = useRef(null);
89
92
  const theme = library.settings.theme;
90
93
  const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
91
94
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
@@ -187,6 +190,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
187
190
  lastStationContextKeyRef.current = renderedStationContextKey;
188
191
  }
189
192
  }, [renderedStationContextKey, screen, selected]);
193
+ useEffect(() => {
194
+ const current = {
195
+ screen,
196
+ receiverStyle: library.settings.receiverStyle,
197
+ playbackState: playback.state,
198
+ playbackReady: playback.ready
199
+ };
200
+ if (shouldResetReceiverPulse(receiverPulseSnapshotRef.current, current)) {
201
+ setPulse(0);
202
+ }
203
+ receiverPulseSnapshotRef.current = current;
204
+ }, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
190
205
  useEffect(() => {
191
206
  if (!shouldAnimateReceiver(screen, playback) ||
192
207
  library.settings.reduceMotion ||
@@ -195,7 +210,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
195
210
  return;
196
211
  }
197
212
  const intervalMs = LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle) ? LIVE_RECEIVER_PULSE_MS : AMBIENT_RECEIVER_PULSE_MS;
198
- const timer = setInterval(() => setPulse(value => (value + 1) % 240), intervalMs);
213
+ const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
199
214
  return () => clearInterval(timer);
200
215
  }, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
201
216
  useEffect(() => {
@@ -223,6 +238,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
223
238
  if (exploreMoveTimerRef.current) {
224
239
  clearTimeout(exploreMoveTimerRef.current);
225
240
  }
241
+ if (transientMessageTimerRef.current) {
242
+ clearTimeout(transientMessageTimerRef.current);
243
+ }
226
244
  }, []);
227
245
  useEffect(() => {
228
246
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
@@ -669,16 +687,27 @@ export function App({ store: providedStore, providers: providedProviders }) {
669
687
  const togglePause = useCallback(() => {
670
688
  void player.togglePause().then(showControlResult);
671
689
  }, [player, showControlResult]);
690
+ const showTransientMessage = useCallback((nextMessage) => {
691
+ if (transientMessageTimerRef.current) {
692
+ clearTimeout(transientMessageTimerRef.current);
693
+ }
694
+ setMessage(nextMessage);
695
+ transientMessageTimerRef.current = setTimeout(() => {
696
+ setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
697
+ transientMessageTimerRef.current = null;
698
+ }, VISUALIZER_MESSAGE_MS);
699
+ }, []);
672
700
  const cycleDisplayColor = useCallback(() => {
673
701
  const theme = nextTheme(settingsRef.current.theme);
674
702
  updateSettings({ theme });
675
- setMessage(`Display color: ${theme}`);
676
- }, [updateSettings]);
703
+ showTransientMessage(`Display color: ${theme}`);
704
+ }, [showTransientMessage, updateSettings]);
677
705
  const cycleReceiverStyle = useCallback(() => {
678
706
  const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
707
+ setPulse(0);
679
708
  updateSettings({ receiverStyle });
680
- setMessage(`Receiver style: ${receiverStyle}`);
681
- }, [updateSettings]);
709
+ showTransientMessage(`Receiver style: ${receiverStyle}`);
710
+ }, [showTransientMessage, updateSettings]);
682
711
  const toggleRadioGarden = useCallback(() => {
683
712
  const enableRadioGarden = !settingsRef.current.enableRadioGarden;
684
713
  updateSettings({ enableRadioGarden });
@@ -987,7 +1016,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
987
1016
  playbackBackend: playback.backend,
988
1017
  screen
989
1018
  });
990
- 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 }), message ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: themeAccent(theme), children: message }) })) : 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) : 'gray', children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }) }));
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) })] })] }) }));
991
1020
  }
992
1021
  function buildLibraryStations(library) {
993
1022
  const stations = [];
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { themeAccent } from './theme.js';
3
+ import { textMuted, themeAccent } from './theme.js';
4
4
  import { HomeScreen } from './screens/HomeScreen.js';
5
5
  import { CountriesScreen } from './screens/CountriesScreen.js';
6
6
  import { MapScreen } from './screens/MapScreen.js';
@@ -17,7 +17,7 @@ import { selectedAirPlayDevice } from './airplay-settings.js';
17
17
  import { playbackBackendLabel } from '../player/backend-install.js';
18
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 }) {
19
19
  if (layout.compact) {
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: "gray", children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: "gray", children: ["Playback: ", playback.state, " \u00B7 ", playbackBackendLabel(playback.backend)] }), _jsx(Text, { color: "gray", children: "q quit \u00B7 Ctrl+C always exits" })] }));
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
  }
22
22
  if (screen === 'home') {
23
23
  return _jsx(HomeScreen, { selected: selected, theme: theme, library: library, playback: playback });
@@ -52,6 +52,15 @@ export const topTabs = [
52
52
  export function clamp(value, max) {
53
53
  return Math.min(Math.max(value, 0), Math.max(max, 0));
54
54
  }
55
+ export function searchEditingArrowAction(key, hasResults) {
56
+ if (key.upArrow) {
57
+ return key.ctrl || !hasResults ? 'history-older' : 'select-previous';
58
+ }
59
+ if (key.downArrow) {
60
+ return key.ctrl || !hasResults ? 'history-newer' : 'select-next';
61
+ }
62
+ return null;
63
+ }
55
64
  export function clampVolume(value) {
56
65
  return Math.min(100, Math.max(0, Math.round(value)));
57
66
  }
@@ -164,6 +173,24 @@ export function stationContextKeyForScreen(screen) {
164
173
  export function shouldAnimateReceiver(screen, playback) {
165
174
  return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
166
175
  }
176
+ export function nextReceiverPulse(pulse) {
177
+ return pulse + 1;
178
+ }
179
+ export function shouldResetReceiverPulse(previous, current) {
180
+ if (current.receiverStyle !== 'ultracode') {
181
+ return false;
182
+ }
183
+ const currentActive = current.screen === 'now-playing' &&
184
+ current.playbackState === 'playing' &&
185
+ current.playbackReady;
186
+ if (!currentActive) {
187
+ return false;
188
+ }
189
+ return !(previous?.screen === 'now-playing' &&
190
+ previous.receiverStyle === 'ultracode' &&
191
+ previous.playbackState === 'playing' &&
192
+ previous.playbackReady);
193
+ }
167
194
  export function isEditableInput(input, key) {
168
195
  return Boolean(key.backspace || key.delete || input);
169
196
  }
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { textMuted } from '../theme.js';
3
4
  const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
4
5
  function LogoSpectrum() {
5
6
  return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
6
7
  }
7
8
  export function Logo() {
8
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color: "gray", children: "Live public radio from around the world" })] }));
9
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color: textMuted, children: "Live public radio from around the world" })] }));
9
10
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { textDim, themeAccent } from '../theme.js';
3
+ import { textDim, textMuted, themeAccent } from '../theme.js';
4
4
  import { useDisplay } from '../display-context.js';
5
5
  import { toAsciiSafe } from '../ascii.js';
6
6
  import { truncate } from '../format.js';
@@ -15,5 +15,5 @@ export function ScreenHeader({ title, width, theme, subtitle, right }) {
15
15
  const rightText = right ? ` ${right}` : '';
16
16
  const ruleWidth = Math.max(1, width - safeTitle.length - rightText.length - 2);
17
17
  const rule = (ascii ? '-' : '─').repeat(ruleWidth);
18
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: "gray", children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: "gray", children: a(truncate(subtitle, width)) }) : null] }));
18
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
19
19
  }
@@ -1,23 +1,23 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
4
- import { themeAccent } from '../theme.js';
4
+ import { textMuted, themeAccent } from '../theme.js';
5
5
  import { Menu, Pointer } from './Menu.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
7
  export function StationList({ stations, selected, theme, favorites, pageSize, width }) {
8
8
  if (stations.length === 0) {
9
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No stations here yet." }), _jsx(Text, { color: "gray", children: "Try a different search or country, or clear active filters with :clear." })] }));
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "No stations here yet." }), _jsx(Text, { color: textMuted, children: "Try a different search or country, or clear active filters with :clear." })] }));
10
10
  }
11
11
  const window = visibleWindow(stations, selected, pageSize);
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: "gray", 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) => {
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: "gray", children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children: _jsxs(Text, { color: "gray", children: ["#", window.start + index + 1, " \u00B7 ", truncate(stationTags(station), rowWidth - 8)] }) })) : null] }));
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: _jsxs(Text, { color: textMuted, children: ["#", window.start + index + 1, " \u00B7 ", truncate(stationTags(station), rowWidth - 8)] }) })) : null] }));
22
22
  } })] }));
23
23
  }
@@ -1,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { panelBorder, themeAccent } from '../theme.js';
3
+ import { panelBorder, textMuted, themeAccent } from '../theme.js';
4
4
  import { useDisplay } from '../display-context.js';
5
5
  import { truncate } from '../format.js';
6
6
  export function TopTabs({ tabs, active, theme, width, rightLabel }) {
@@ -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: "gray", children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : 'gray', bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: "gray", children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: "gray", 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: 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] })] }));
24
24
  }
25
25
  function fitTabs(tabs, active, maxWidth) {
26
26
  const all = tabs.map(tab => ({ type: 'tab', tab }));
@@ -10,7 +10,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
10
10
  return '↑/↓ move · Enter open · number jump · : command';
11
11
  }
12
12
  if (screen === 'search' && editingSearch) {
13
- return 'Type query · ↑/↓ history · Enter search/tune · Esc finish';
13
+ return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
14
14
  }
15
15
  if (screen === 'search') {
16
16
  return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b home';
@@ -2,16 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { ScreenHeader } from '../components/ScreenHeader.js';
4
4
  import { truncate } from '../format.js';
5
- import { textDim, themeAccent } from '../theme.js';
5
+ import { textDim, textMuted, themeAccent } from '../theme.js';
6
6
  export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
7
7
  const accent = themeAccent(theme);
8
8
  const lineWidth = Math.max(24, width - 4);
9
9
  const promptActive = isAirPlayCodePromptActive(playback);
10
10
  const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
11
11
  const masked = code.length > 0 ? '*'.repeat(code.length) : '';
12
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: "gray", children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
12
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
13
13
  ? 'The receiver is asking for a code now.'
14
- : 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: "Enter submits \u00B7 Backspace edits \u00B7 Esc returns to AirPlay" })] })] }));
14
+ : 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: "Enter submits \u00B7 Backspace edits \u00B7 Esc returns to AirPlay" })] })] }));
15
15
  }
16
16
  export function isAirPlayCodePromptActive(playback) {
17
17
  return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
@@ -5,7 +5,7 @@ import { audioOutputLabel } from '../audio-output.js';
5
5
  import { Menu, Pointer } from '../components/Menu.js';
6
6
  import { ScreenHeader } from '../components/ScreenHeader.js';
7
7
  import { truncate } from '../format.js';
8
- import { textDim, themeAccent } from '../theme.js';
8
+ import { textDim, textMuted, themeAccent } from '../theme.js';
9
9
  export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width }) {
10
10
  const accent = themeAccent(theme);
11
11
  const availability = airPlayAvailability(backends, devices);
@@ -14,11 +14,11 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
14
14
  const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
15
15
  const lineWidth = Math.max(24, width - 4);
16
16
  const preferredOutput = audioOutputLabel(settings.preferredBackend);
17
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? 'gray' : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: "gray", children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: "gray", children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: devices, selected: selected, keyFor: device => device.id, render: (device, _index, active) => {
17
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: devices, selected: selected, keyFor: device => device.id, render: (device, _index, active) => {
18
18
  const isSelected = device.id === settings.preferredAirPlayDevice;
19
19
  const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
20
20
  const selectedLabel = isSelected ? ' selected' : '';
21
21
  const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
22
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color: "gray", children: [" \u00B7 ", detail] })] }));
23
- } })) : (_jsx(Text, { color: "gray", children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
22
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color: textMuted, children: [" \u00B7 ", detail] })] }));
23
+ } })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
24
24
  }
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { Menu, Pointer } from '../components/Menu.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { themeAccent } from '../theme.js';
5
+ import { textMuted, themeAccent } from '../theme.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
7
  export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
8
8
  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: "gray", children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", 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: "gray", children: [" \u00B7 ", country.code, " \u00B7 ", country.stationCount.toLocaleString(), " stations"] })] })) })] })) : null] }));
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
10
  }
@@ -5,7 +5,7 @@ import { StationList } from '../components/StationList.js';
5
5
  import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
6
  import { computeExploreMapLayout } from '../explore-map-layout.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
- import { exploreMapLand, mapMarker, panelBorder, themeAccent } from '../theme.js';
8
+ import { exploreMapLand, mapMarker, panelBorder, textMuted, themeAccent } from '../theme.js';
9
9
  import { panelBorderStyle, useDisplay } from '../display-context.js';
10
10
  import { toAsciiSafe } from '../ascii.js';
11
11
  export function ExploreScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
@@ -13,7 +13,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, th
13
13
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
14
14
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
15
15
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
16
- return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: "gray", children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
16
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
17
17
  }
18
18
  function CosmoMapLine({ row, theme }) {
19
19
  const { ascii } = useDisplay();
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { themeAccent } from '../theme.js';
3
+ import { textMuted, themeAccent } from '../theme.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { commandHelp, keyHelpSections } from '../help-content.js';
6
6
  import { truncate } from '../format.js';
@@ -8,5 +8,5 @@ export function HelpScreen({ theme, width }) {
8
8
  const accent = themeAccent(theme);
9
9
  const keyColumnWidth = 16;
10
10
  const lineWidth = Math.max(28, width - 2);
11
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: "gray", children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: "gray", children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
11
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: textMuted, children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: textMuted, children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
12
12
  }
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { Logo } from '../components/Logo.js';
4
4
  import { Menu, Pointer } from '../components/Menu.js';
5
- import { themeAccent } from '../theme.js';
5
+ import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
7
  import { playbackBackendLabel } from '../../player/backend-install.js';
8
8
  export function HomeScreen({ selected, theme, library, playback }) {
9
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
10
10
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { mapLand, mapWater, themeAccent, themeContributionColors } from '../theme.js';
3
+ import { mapLand, mapWater, textMuted, themeAccent, themeContributionColors } from '../theme.js';
4
4
  import { useDisplay } from '../display-context.js';
5
5
  import { toAsciiSafe } from '../ascii.js';
6
6
  import { visibleWindow } from '../list-window.js';
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
18
18
  const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
19
19
  const topWidth = mode === 'full' ? 50 : contentWidth;
20
20
  const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: "gray", children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: "gray", children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _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.code }), _jsx(Text, { color: "gray", children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _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.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
22
22
  }
23
23
  function MapLine({ row, theme }) {
24
24
  const { ascii } = useDisplay();
@@ -55,7 +55,7 @@ function mapColor(kind, theme) {
55
55
  return colors[2] ?? themeAccent(theme);
56
56
  }
57
57
  if (kind === 'level1') {
58
- return colors[1] ?? 'gray';
58
+ return colors[1] ?? textMuted;
59
59
  }
60
60
  if (kind === 'land') {
61
61
  return mapLand;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
4
- import { themeAccent } from '../theme.js';
4
+ import { textMuted, themeAccent } from '../theme.js';
5
5
  import { panelBorderStyle, useDisplay } from '../display-context.js';
6
6
  import { toAsciiSafe } from '../ascii.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
@@ -36,9 +36,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
36
36
  const dialLabel = receiverDialLabel(station);
37
37
  const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
38
38
  const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
39
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: "gray", children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
39
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: textMuted, children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
40
40
  ? renderSegments(row.segments)
41
- : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children: a(favoriteText) })] }), _jsx(Text, { color: "gray", children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Diagnostics" }), _jsx(Text, { color: "gray", children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: "gray", children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: "gray", children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: "gray", children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
41
+ : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), _jsx(Text, { color: textMuted, children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
42
42
  }
43
43
  function asciifyVisualRow(row) {
44
44
  return {
@@ -95,8 +95,8 @@ function stationFrequency(name) {
95
95
  function renderSegments(segments) {
96
96
  let offset = 0;
97
97
  return segments.map(segment => {
98
- const key = `${offset}-${segment.color}`;
98
+ const key = `${offset}-${segment.color}-${segment.backgroundColor ?? ''}-${segment.bold ? 'bold' : ''}`;
99
99
  offset += segment.text.length;
100
- return (_jsx(Text, { color: segment.color, children: segment.text }, key));
100
+ return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: segment.text }, key));
101
101
  });
102
102
  }