@ciphore/radiocli 0.1.7 → 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,25 @@ 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
+
10
29
  ## [0.1.7] - 2026-06-29
11
30
 
12
31
  ### Changed
@@ -158,6 +177,7 @@ Initial public release.
158
177
  [0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
159
178
  [0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
160
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
161
181
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
162
182
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
163
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
 
@@ -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 });
@@ -173,6 +173,24 @@ 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 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
+ }
176
194
  export function isEditableInput(input, key) {
177
195
  return Boolean(key.backspace || key.delete || input);
178
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 }));
@@ -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
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { StationList } from '../components/StationList.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { panelBorder, themeAccent } from '../theme.js';
5
+ import { panelBorder, textMuted, themeAccent } from '../theme.js';
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { truncate } from '../format.js';
8
8
  export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
@@ -10,5 +10,5 @@ export function SearchScreen({ query, editing, loading, stations, selected, them
10
10
  const contentWidth = Math.max(40, width);
11
11
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
12
12
  const inputTextWidth = Math.max(8, inputWidth - 8);
13
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : 'gray', children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : 'gray', children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: "gray", children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
13
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
14
14
  }
@@ -4,7 +4,7 @@ import { Menu, Pointer } from '../components/Menu.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { truncate } from '../format.js';
6
6
  import { settingsItems } from '../screen-items.js';
7
- import { themeAccent } from '../theme.js';
7
+ import { textMuted, themeAccent } from '../theme.js';
8
8
  import { playbackBackendCapabilities } from '../../player/backend-install.js';
9
9
  import { airPlayReceiverSettingValue } from '../airplay-settings.js';
10
10
  import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
@@ -14,8 +14,8 @@ export function SettingsScreen({ selected, settings, storePath, playback, backen
14
14
  const health = Object.entries(providerHealth);
15
15
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: settingsItems, selected: selected, keyFor: item => item, render: (item, _index, active) => {
16
16
  const value = settingValue(item, settings, diagnostics, backends, airPlayDevices);
17
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: item }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
18
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: "gray", children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: "gray", children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: "gray", children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
17
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: item }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: textMuted, children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
18
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Status" }), _jsxs(Text, { color: textMuted, children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: textMuted, children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: textMuted, children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: textMuted, children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
19
19
  }
20
20
  function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
21
21
  switch (item) {
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { StationList } from '../components/StationList.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
+ import { textMuted } from '../theme.js';
5
6
  export function StationScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, pageSize, width }) {
6
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: "gray", children: "Loading stations\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] })] }));
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: textMuted, children: "Loading stations\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] })] }));
7
8
  }
@@ -1,12 +1,20 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
2
3
  import { Box, Text } from 'ink';
3
4
  import { computeListeningStats } from '../../activity/stats.js';
4
- import { panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
5
+ import { panelBorder, textHighlight, textMuted, themeAccent, themeContributionColors } from '../theme.js';
5
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
6
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
7
8
  import { truncate } from '../format.js';
8
9
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
9
- const heatmapSquare = '■';
10
+ const dayLabelWidth = 5;
11
+ const heatmapCellOptions = [
12
+ { cell: ' ', gap: ' ' },
13
+ { cell: ' ', gap: '' },
14
+ { cell: '■', gap: '' }
15
+ ];
16
+ const compactHeatmapCell = '■';
17
+ const compactHeatmapGap = '';
10
18
  export function StatsScreen({ library, theme, width, height }) {
11
19
  const { panel: panelBackground, ascii } = useDisplay();
12
20
  const stats = computeListeningStats(library.activity.sessions);
@@ -18,31 +26,65 @@ export function StatsScreen({ library, theme, width, height }) {
18
26
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
19
27
  const favoriteWidth = Math.max(8, metricWidth - 18);
20
28
  const compact = height < 30;
21
- 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: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, 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: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: [heatmapSquare, _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
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
22
31
  ? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
23
32
  : 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
24
33
  }
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));
41
+ }
42
+ return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: color, children: cell.text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }, cell.key));
43
+ }
44
+ function renderLegendCell(color, text, cellGap) {
45
+ if (text.trim().length === 0) {
46
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: color, children: text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }));
47
+ }
48
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: color, children: text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }));
49
+ }
25
50
  function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, theme) {
26
51
  const accent = themeAccent(theme);
27
52
  const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
28
- return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", 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: "gray", children: [rightLabel, ": "] }), _jsx(Text, { color: accent, children: rightValue })] }) })] }));
53
+ 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 })] }) })] }));
29
54
  }
30
- function buildContributionGraph(days, width) {
31
- const cellWidth = width >= 112 ? 2 : 1;
32
- const weeks = Array.from({ length: 53 }, (_, weekIndex) => days.slice(weekIndex * 7, weekIndex * 7 + 7)).filter(week => week.length > 0);
33
- const scaleSeconds = contributionScaleSeconds(days);
55
+ export function buildContributionGraph(days, width) {
56
+ const year = graphYear(days);
57
+ const weeks = calendarYearWeeks(year);
58
+ const largestCell = heatmapCellOptions.find(option => dayLabelWidth + weeks.length * (option.cell.length + option.gap.length) <= width);
59
+ const selectedCell = largestCell ?? { cell: compactHeatmapCell, gap: compactHeatmapGap };
60
+ const largeCellWidth = selectedCell.cell.length + selectedCell.gap.length;
61
+ const compactCellWidth = compactHeatmapCell.length + compactHeatmapGap.length;
62
+ const cellWidth = largeCellWidth > 0 ? largeCellWidth : compactCellWidth;
63
+ const cellText = selectedCell.cell;
64
+ const cellGap = selectedCell.gap;
65
+ const secondsByDate = new Map(days.map(day => [day.date, day.seconds]));
66
+ const yearDays = days.filter(day => parseLocalDay(day.date).getFullYear() === year);
67
+ const scaleSeconds = contributionScaleSeconds(yearDays);
34
68
  const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
35
69
  const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
36
70
  key: `day-${dayIndex}`,
37
71
  label: labels[dayIndex] ?? '',
38
72
  cells: weeks.map(week => {
39
73
  const day = week[dayIndex];
40
- const level = contributionLevel(day?.seconds ?? 0, scaleSeconds);
41
- const text = cellWidth > 1 ? `${heatmapSquare} ` : heatmapSquare;
42
- return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text };
74
+ if (!day) {
75
+ return {
76
+ key: `empty-${dayIndex}`,
77
+ level: 0,
78
+ text: ' '.repeat(cellWidth),
79
+ visible: false
80
+ };
81
+ }
82
+ const date = localDay(day.date);
83
+ const level = day.visible ? contributionLevel(secondsByDate.get(date) ?? 0, scaleSeconds) : 0;
84
+ return { key: date, level, text: cellText, visible: true };
43
85
  })
44
86
  }));
45
- return { months: monthLine(weeks, cellWidth), rows };
87
+ return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, rows };
46
88
  }
47
89
  export function contributionScaleSeconds(days) {
48
90
  const activeSeconds = days
@@ -71,29 +113,55 @@ export function contributionLevel(seconds, scaleSeconds) {
71
113
  }
72
114
  return 1;
73
115
  }
74
- function monthLine(weeks, cellWidth) {
75
- const cells = weeks.map((week, index) => {
76
- const first = week[0];
77
- if (!first) {
78
- return ''.padEnd(cellWidth, ' ');
116
+ function graphYear(days) {
117
+ const lastDay = days[days.length - 1];
118
+ return lastDay ? parseLocalDay(lastDay.date).getFullYear() : new Date().getFullYear();
119
+ }
120
+ function calendarYearWeeks(year) {
121
+ const yearStart = new Date(year, 0, 1);
122
+ const yearEnd = new Date(year, 11, 31);
123
+ const gridStart = addLocalDays(yearStart, -yearStart.getDay());
124
+ const gridEnd = addLocalDays(yearEnd, 6 - yearEnd.getDay());
125
+ const weeks = [];
126
+ for (let cursor = gridStart; cursor.getTime() <= gridEnd.getTime(); cursor = addLocalDays(cursor, 7)) {
127
+ weeks.push(Array.from({ length: 7 }, (_, dayIndex) => {
128
+ const date = addLocalDays(cursor, dayIndex);
129
+ return { date, visible: date.getFullYear() === year };
130
+ }));
131
+ }
132
+ return weeks;
133
+ }
134
+ function monthLine(weeks, cellWidth, year) {
135
+ const cells = Array.from({ length: weeks.length * cellWidth }, () => ' ');
136
+ for (let month = 0; month < 12; month += 1) {
137
+ const label = monthLabels[month];
138
+ const firstOfMonth = localDay(new Date(year, month, 1));
139
+ const weekIndex = weeks.findIndex(week => week.some(day => localDay(day.date) === firstOfMonth));
140
+ if (weekIndex < 0) {
141
+ continue;
79
142
  }
80
- const date = parseLocalDay(first.date);
81
- const previous = index > 0 && weeks[index - 1]?.[0]
82
- ? parseLocalDay(weeks[index - 1][0].date)
83
- : null;
84
- const changedMonth = !previous || date.getMonth() !== previous.getMonth();
85
- if (!changedMonth) {
86
- return ''.padEnd(cellWidth, ' ');
143
+ const start = weekIndex * cellWidth;
144
+ for (let index = 0; index < label.length && start + index < cells.length; index += 1) {
145
+ cells[start + index] = label[index];
87
146
  }
88
- const label = monthLabels[date.getMonth()];
89
- return cellWidth >= 3 ? label : label.slice(0, cellWidth).padEnd(cellWidth, ' ');
90
- });
147
+ }
91
148
  return cells.join('');
92
149
  }
93
150
  function parseLocalDay(value) {
94
151
  const [year = '0', month = '1', day = '1'] = value.split('-');
95
152
  return new Date(Number(year), Number(month) - 1, Number(day));
96
153
  }
154
+ function localDay(date) {
155
+ const year = date.getFullYear();
156
+ const month = String(date.getMonth() + 1).padStart(2, '0');
157
+ const day = String(date.getDate()).padStart(2, '0');
158
+ return `${year}-${month}-${day}`;
159
+ }
160
+ function addLocalDays(date, days) {
161
+ const next = new Date(date.getTime());
162
+ next.setDate(next.getDate() + days);
163
+ return new Date(next.getFullYear(), next.getMonth(), next.getDate());
164
+ }
97
165
  function formatDays(days) {
98
166
  return `${days} ${days === 1 ? 'day' : 'days'}`;
99
167
  }
@@ -6,7 +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 { contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
9
+ import { buildContributionGraph, contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
10
10
  import { settingsItems } from '../screen-items.js';
11
11
  import { defaultExploreCursor } from '../app-state.js';
12
12
  const station = {
@@ -83,7 +83,7 @@ describe('Explore world map rendering', () => {
83
83
  });
84
84
  });
85
85
  describe('StatsScreen rendering', () => {
86
- it('renders activity heatmap days as square markers instead of stretched blocks', () => {
86
+ it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
87
87
  const library = {
88
88
  recent: [],
89
89
  favorites: [],
@@ -104,9 +104,40 @@ describe('StatsScreen rendering', () => {
104
104
  settings
105
105
  };
106
106
  const frame = render(_jsx(DisplayContext.Provider, { value: resolveDisplayMode({ asciiMode: false }, {}), children: _jsx(StatsScreen, { library: library, theme: "green", width: 132, height: 32 }) })).lastFrame() ?? '';
107
- expect(frame).toContain('');
107
+ expect(frame).toContain('Jan');
108
108
  expect(frame).not.toContain('██');
109
109
  });
110
+ it('starts the contribution graph at January and uses large cells when space allows', () => {
111
+ const graph = buildContributionGraph([
112
+ { date: '2026-01-01', seconds: 3600 },
113
+ { date: '2026-12-31', seconds: 0 }
114
+ ], 170);
115
+ expect(graph.months.startsWith('Jan')).toBe(true);
116
+ expect(graph.months).toContain('Dec');
117
+ expect(graph.cellText).toBe(' ');
118
+ expect(graph.cellGap).toBe(' ');
119
+ expect(graph.rows[4]?.cells[0]).toMatchObject({
120
+ level: 4,
121
+ text: ' ',
122
+ visible: true
123
+ });
124
+ expect(graph.rows[0]?.cells[0]).toMatchObject({
125
+ level: 0,
126
+ text: ' ',
127
+ visible: true
128
+ });
129
+ expect(graph.rows[6]?.cells.at(-1)).toMatchObject({
130
+ level: 0,
131
+ text: ' ',
132
+ visible: true
133
+ });
134
+ const normalWidthGraph = buildContributionGraph([
135
+ { date: '2026-01-01', seconds: 3600 },
136
+ { date: '2026-12-31', seconds: 0 }
137
+ ], 128);
138
+ expect(normalWidthGraph.cellText).toBe(' ');
139
+ expect(normalWidthGraph.cellGap).toBe('');
140
+ });
110
141
  it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
111
142
  const days = [
112
143
  ...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
package/dist/ui/theme.js CHANGED
@@ -5,7 +5,10 @@ export const panelBackground = '#090d14';
5
5
  export const panelBorder = '#28313c';
6
6
  // Shared neutral palette. Use these named tokens instead of ad-hoc hex literals
7
7
  // so secondary text, rules, and map shading stay consistent across every screen.
8
- export const textDim = '#5b6573';
8
+ // Keep these as hex truecolor values rather than ANSI names like "gray";
9
+ // named gray maps to terminal-palette "bright black" and can become unreadable.
10
+ export const textMuted = '#aab4c2';
11
+ export const textDim = '#8a96a8';
9
12
  export const textHighlight = '#d8c66f';
10
13
  export const mapLand = '#33414f';
11
14
  export const mapWater = '#1a2430';
@@ -28,7 +31,7 @@ export function themeAccent(theme) {
28
31
  return '#5eead4';
29
32
  }
30
33
  if (theme === 'violet') {
31
- return '#a78bfa';
34
+ return '#8c50f0';
32
35
  }
33
36
  if (theme === 'copper') {
34
37
  return '#d08770';
@@ -71,7 +74,7 @@ export function themeContributionColors(theme) {
71
74
  return [emptyContribution, '#123f3c', '#1f766c', '#2dd4bf', '#5eead4'];
72
75
  }
73
76
  if (theme === 'violet') {
74
- return [emptyContribution, '#302047', '#5b3f8f', '#7c5cff', '#a78bfa'];
77
+ return [emptyContribution, '#3e1676', '#542799', '#763fcd', '#8c50f0'];
75
78
  }
76
79
  if (theme === 'copper') {
77
80
  return [emptyContribution, '#44281f', '#7f4f37', '#b86f52', '#d08770'];
@@ -3,8 +3,8 @@ export function buildVisualizer(style, pulse, width, height, _station, playback,
3
3
  if (!playbackHasSignal(playback)) {
4
4
  return buildZeroSignalVisualizer(width, height, theme);
5
5
  }
6
- if (style === 'equalizer') {
7
- return buildEqualizer(pulse, width, height, theme);
6
+ if (style === 'ultracode') {
7
+ return buildUltracode(pulse, width, height, theme);
8
8
  }
9
9
  if (style === 'motion-blob') {
10
10
  return buildMotionBlob(pulse, width, height, theme);
@@ -153,7 +153,7 @@ export function buildVisualizer(style, pulse, width, height, _station, playback,
153
153
  if (style === 'newton') {
154
154
  return buildNewton(pulse, width, height, theme);
155
155
  }
156
- return buildEqualizer(pulse, width, height, theme);
156
+ return buildUltracode(pulse, width, height, theme);
157
157
  }
158
158
  function playbackHasSignal(playback) {
159
159
  return playback.state === 'playing' && playback.ready;
@@ -172,71 +172,80 @@ function buildFlatZeroSignal(width, requestedHeight, theme) {
172
172
  color: rowIndex === height - 1 ? accent : '#767676'
173
173
  }));
174
174
  }
175
- function buildEqualizer(pulse, width, height, theme) {
175
+ const ultracodeWavelength = 20;
176
+ const ultracodeTravelPerPulse = 80 * 0.03;
177
+ const ultracodeVioletRamp = ['#3e1676', '#491e87', '#542799', '#5f2faa', '#6b37bc', '#763fcd', '#8148df', '#8c50f0'];
178
+ function buildUltracode(pulse, width, height, theme) {
179
+ const renderWidth = Math.max(1, width);
180
+ const renderHeight = Math.max(1, height);
176
181
  const accent = themeAccent(theme);
177
- const bandWidth = 3;
178
- const bandCount = Math.floor((width - 2) / bandWidth);
179
- const levels = Array.from({ length: bandCount }, (_, i) => {
180
- const low = Math.sin(i * 0.18 + pulse * 0.46);
181
- const mid = Math.cos(i * 0.32 - pulse * 0.28);
182
- const high = Math.sin(i * 0.45 + pulse * 0.68);
183
- const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
184
- const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
185
- return Math.round(eased * height);
186
- });
187
- const peaks = Array.from({ length: bandCount }, (_, i) => {
188
- let maxLvl = 0;
189
- for (let k = 0; k < 8; k++) {
190
- const p = (pulse - k + 240) % 240;
191
- const low = Math.sin(i * 0.18 + p * 0.46);
192
- const mid = Math.cos(i * 0.32 - p * 0.28);
193
- const high = Math.sin(i * 0.45 + p * 0.68);
194
- const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
195
- const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
196
- const lvl = Math.round(eased * height);
197
- if (lvl > maxLvl) {
198
- maxLvl = lvl;
199
- }
200
- }
201
- return maxLvl;
202
- });
203
- const rows = [];
204
- const colors = themeContributionColors(theme);
205
- for (let rowIndex = 0; rowIndex < height; rowIndex++) {
206
- const threshold = height - rowIndex;
207
- let rowText = ' ';
208
- for (let i = 0; i < bandCount; i++) {
209
- const lvl = levels[i];
210
- const peak = peaks[i];
211
- let bandChar = ' ';
212
- if (lvl >= threshold) {
213
- bandChar = '██';
214
- }
215
- else if (peak === threshold) {
216
- bandChar = '◆◆';
217
- }
218
- else {
219
- bandChar = '··';
220
- }
221
- rowText += bandChar + ' ';
222
- }
223
- let color = colors[2] ?? accent;
224
- if (rowIndex < Math.max(1, height * 0.3)) {
225
- color = '#ff5f87';
226
- }
227
- else if (rowIndex < Math.max(2, height * 0.6)) {
228
- color = colors[4] ?? accent;
229
- }
230
- else {
231
- color = colors[3] ?? accent;
232
- }
233
- rows.push({
234
- text: rowText.padEnd(width).slice(0, width),
235
- color
182
+ const ramp = ultracodeRippleRamp(theme);
183
+ const selectedColor = ramp[ramp.length - 1] ?? accent;
184
+ const travel = pulse * ultracodeTravelPerPulse;
185
+ const originColumn = Math.floor(renderWidth / 2);
186
+ const originRow = Math.floor(renderHeight / 2);
187
+ const rows = Array.from({ length: renderHeight }, (_, rowIndex) => {
188
+ const cells = Array.from({ length: renderWidth }, (_, columnIndex) => {
189
+ const level = ultracodeRippleLevel(ultracodeDistance(columnIndex, rowIndex, originColumn, originRow), travel, ramp.length);
190
+ return {
191
+ text: ' ',
192
+ color: level === null ? accent : selectedColor,
193
+ backgroundColor: level === null ? undefined : ramp[level] ?? selectedColor
194
+ };
236
195
  });
237
- }
196
+ return lineFromCells(cells, accent);
197
+ });
238
198
  return rows;
239
199
  }
200
+ function ultracodeDistance(column, row, originColumn, originRow) {
201
+ const dx = column - originColumn;
202
+ const dy = (row - originRow) * 2;
203
+ return Math.sqrt(dx * dx + dy * dy);
204
+ }
205
+ function ultracodeRippleLevel(distance, travel, rampLength) {
206
+ if (distance > travel) {
207
+ return null;
208
+ }
209
+ const phase = (((distance - travel) % ultracodeWavelength) + ultracodeWavelength) % ultracodeWavelength;
210
+ const brightness = (1 + Math.cos((2 * Math.PI * phase) / ultracodeWavelength)) / 2;
211
+ return Math.min(rampLength - 1, Math.round(brightness * (rampLength - 1)));
212
+ }
213
+ function ultracodeRippleRamp(theme) {
214
+ if (theme === 'violet') {
215
+ return ultracodeVioletRamp;
216
+ }
217
+ const colors = themeContributionColors(theme);
218
+ const start = colors[1] ?? '#1c1c1c';
219
+ const end = themeAccent(theme);
220
+ return Array.from({ length: 8 }, (_, index) => interpolateHex(start, end, index / 7));
221
+ }
222
+ function interpolateHex(startHex, endHex, amount) {
223
+ const start = hexToRgb(startHex);
224
+ const end = hexToRgb(endHex);
225
+ return rgbToHex([
226
+ Math.round(start[0] + (end[0] - start[0]) * amount),
227
+ Math.round(start[1] + (end[1] - start[1]) * amount),
228
+ Math.round(start[2] + (end[2] - start[2]) * amount)
229
+ ]);
230
+ }
231
+ function hexToRgb(hex) {
232
+ const normalized = hex.replace('#', '');
233
+ const expanded = normalized.length === 3
234
+ ? [...normalized].map(value => value + value).join('')
235
+ : normalized.padEnd(6, '0').slice(0, 6);
236
+ const value = Number.parseInt(expanded, 16);
237
+ return [
238
+ (value >> 16) & 255,
239
+ (value >> 8) & 255,
240
+ value & 255
241
+ ];
242
+ }
243
+ function rgbToHex([red, green, blue]) {
244
+ return `#${[red, green, blue].map(value => clampColor(value).toString(16).padStart(2, '0')).join('')}`;
245
+ }
246
+ function clampColor(value) {
247
+ return Math.max(0, Math.min(255, Math.round(value)));
248
+ }
240
249
  function buildMotionBlob(pulse, width, height, theme) {
241
250
  const h = Math.max(7, height);
242
251
  const mid = (h - 1) / 2;
@@ -1876,11 +1885,19 @@ function lineFromCells(cells, fallbackColor) {
1876
1885
  const segments = [];
1877
1886
  for (const cell of cells) {
1878
1887
  const previous = segments[segments.length - 1];
1879
- if (previous && previous.color === cell.color) {
1888
+ if (previous &&
1889
+ previous.color === cell.color &&
1890
+ previous.backgroundColor === cell.backgroundColor &&
1891
+ previous.bold === cell.bold) {
1880
1892
  previous.text += cell.text;
1881
1893
  }
1882
1894
  else {
1883
- segments.push({ text: cell.text, color: cell.color });
1895
+ segments.push({
1896
+ text: cell.text,
1897
+ color: cell.color,
1898
+ backgroundColor: cell.backgroundColor,
1899
+ bold: cell.bold
1900
+ });
1884
1901
  }
1885
1902
  }
1886
1903
  return { text, color: fallbackColor, segments };
@@ -1938,7 +1955,7 @@ function dimMotionColorAt(position, theme) {
1938
1955
  }
1939
1956
  export function visualizerHeight(style, availableRows, width = 80) {
1940
1957
  const maxRowsByStyle = {
1941
- equalizer: 12,
1958
+ ultracode: 14,
1942
1959
  'motion-blob': 12,
1943
1960
  'motion-area': 11,
1944
1961
  'motion-contour': 14,
@@ -1990,7 +2007,7 @@ export function visualizerHeight(style, availableRows, width = 80) {
1990
2007
  newton: 14
1991
2008
  };
1992
2009
  const spaciousStyles = new Set([
1993
- 'equalizer',
2010
+ 'ultracode',
1994
2011
  'motion-blob',
1995
2012
  'motion-area',
1996
2013
  'motion-contour',
@@ -2041,13 +2058,15 @@ export function visualizerHeight(style, availableRows, width = 80) {
2041
2058
  'lava-lamp',
2042
2059
  'newton'
2043
2060
  ]);
2044
- const minRows = style === 'cube' || style === 'motion-contour' || style === 'spinning-donut'
2045
- ? 8
2046
- : spaciousStyles.has(style)
2047
- ? 7
2048
- : style.startsWith('motion-')
2049
- ? 6
2050
- : 3;
2061
+ const minRows = style === 'ultracode'
2062
+ ? 7
2063
+ : style === 'cube' || style === 'motion-contour' || style === 'spinning-donut'
2064
+ ? 8
2065
+ : spaciousStyles.has(style)
2066
+ ? 7
2067
+ : style.startsWith('motion-')
2068
+ ? 6
2069
+ : 3;
2051
2070
  const baseMaxRows = maxRowsByStyle[style] ?? 8;
2052
2071
  const maxRows = responsiveVisualizerMaxRows(style, availableRows, width, baseMaxRows, spaciousStyles);
2053
2072
  return Math.max(minRows, Math.min(maxRows, availableRows));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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",