@ciphore/radiocli 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.9] - 2026-07-03
11
+
12
+ ### Changed
13
+
14
+ - Nearby station discovery is now enabled by default for new libraries, and the
15
+ `l` shortcut is limited to Overview, Nearby, and Settings so country lists
16
+ keep normal letter navigation.
17
+ - Favorite changes from Library now appear in the footer message row instead of
18
+ displacing the main page status.
19
+ - The listening stats activity graph now uses solid truecolor cell backgrounds
20
+ with tighter rendering on short terminals and fewer split color runs.
21
+
22
+ ### Fixed
23
+
24
+ - Nearby keeps showing the last loaded station list when location lookup is
25
+ turned off or temporarily unavailable.
26
+ - Long country names are truncated to one terminal row so dense country lists do
27
+ not wrap into nearby entries.
28
+ - Pressing `b` from a country station list now returns to Countries before
29
+ falling back to Overview.
30
+
31
+ ## [0.1.8] - 2026-07-02
32
+
33
+ ### Changed
34
+
35
+ - Replaced the classic equalizer receiver style with Ultracode, a centered
36
+ color-ripple display that follows the active display color; saved `equalizer`
37
+ preferences migrate to `ultracode`.
38
+ - The listening stats activity graph now renders as a calendar-year grid with
39
+ larger contribution cells and clearer month labels when the terminal has room.
40
+ - Muted UI text now uses truecolor neutrals instead of terminal `gray`, making
41
+ secondary labels more readable across dark terminal palettes.
42
+
43
+ ### Fixed
44
+
45
+ - Stale continuous listening sessions are capped before splitting them across
46
+ days so one long-unclosed playback session cannot dominate listening totals.
47
+ - Receiver visualizer pulses now reset when an active Ultracode view opens but
48
+ keep advancing smoothly during normal playback.
49
+
10
50
  ## [0.1.7] - 2026-06-29
11
51
 
12
52
  ### Changed
@@ -158,6 +198,8 @@ Initial public release.
158
198
  [0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
159
199
  [0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
160
200
  [0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
201
+ [0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
202
+ [0.1.9]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.9
161
203
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
162
204
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
163
205
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/README.md CHANGED
@@ -76,13 +76,13 @@ Live public radio from around the world
76
76
 
77
77
  3 recent · 2 favorites · 1 imported
78
78
 
79
- ↑/↓ move · Enter open · number jump · : command
79
+ ↑/↓ move · Enter open · number jump · l location · : command
80
80
  ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
81
81
  ```
82
82
 
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) {
@@ -28,8 +28,7 @@ const stationSchema = z
28
28
  distanceKm: z.number().optional(),
29
29
  hls: z.boolean().optional(),
30
30
  lastCheckedOk: z.boolean().optional()
31
- })
32
- .strict();
31
+ });
33
32
  const defaultMediaKeys = {
34
33
  previous: [],
35
34
  playPause: [],
@@ -127,6 +126,9 @@ const removedReceiverStyles = new Set([
127
126
  'voronoi'
128
127
  ]);
129
128
  function normalizeReceiverStyle(value) {
129
+ if (value === 'equalizer') {
130
+ return 'ultracode';
131
+ }
130
132
  return typeof value === 'string' && removedReceiverStyles.has(value) ? defaultReceiverStyle : value;
131
133
  }
132
134
  const settingsSchema = z.object({
@@ -135,7 +137,7 @@ const settingsSchema = z.object({
135
137
  receiverStyleVersion: z.number().optional(),
136
138
  volume: z.number().min(0).max(100).default(70),
137
139
  enableRadioGarden: z.boolean().default(false),
138
- enableNearbyLocation: z.boolean().default(false),
140
+ enableNearbyLocation: z.boolean().default(true),
139
141
  preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
140
142
  preferredAirPlayDevice: z.string().min(1).optional(),
141
143
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
@@ -189,7 +191,7 @@ const librarySchema = z.object({
189
191
  receiverStyleVersion: 2,
190
192
  volume: 70,
191
193
  enableRadioGarden: false,
192
- enableNearbyLocation: false,
194
+ enableNearbyLocation: true,
193
195
  preferredBackend: 'auto',
194
196
  tuneTimeoutSeconds: 12,
195
197
  skipBrokenStreams: true,
@@ -384,7 +386,7 @@ function defaultState() {
384
386
  receiverStyleVersion: 2,
385
387
  volume: 70,
386
388
  enableRadioGarden: false,
387
- enableNearbyLocation: false,
389
+ enableNearbyLocation: true,
388
390
  preferredBackend: 'auto',
389
391
  tuneTimeoutSeconds: 12,
390
392
  skipBrokenStreams: true,
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',
@@ -50,6 +51,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
50
51
  const [screen, setScreen] = useState('home');
51
52
  const [selected, setSelected] = useState(0);
52
53
  const [message, setMessage] = useState(null);
54
+ const [footerMessage, setFooterMessage] = useState(null);
53
55
  const [countries, setCountries] = useState([]);
54
56
  const [countryFilter, setCountryFilter] = useState('');
55
57
  const [editingCountryFilter, setEditingCountryFilter] = useState(false);
@@ -86,6 +88,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
86
88
  const exploreCursorRef = useRef(exploreCursor);
87
89
  const exploreRequestRef = useRef(0);
88
90
  const exploreMoveTimerRef = useRef(null);
91
+ const transientMessageTimerRef = useRef(null);
92
+ const transientFooterMessageTimerRef = useRef(null);
93
+ const receiverPulseSnapshotRef = useRef(null);
89
94
  const theme = library.settings.theme;
90
95
  const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
91
96
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
@@ -157,10 +162,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
157
162
  displayStationsRef.current = displayStations;
158
163
  const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
159
164
  const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
165
+ const footerRows = showPlaybackFooter ? 4 : 3;
160
166
  const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
161
167
  const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
162
168
  Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
163
- const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
169
+ const layout = computeTerminalLayout(columns, rows, footerRows);
164
170
  const frameWidth = Math.max(40, layout.columns - 2);
165
171
  useEffect(() => player.onChange(setPlayback), [player]);
166
172
  const playingStationRef = useRef(null);
@@ -187,6 +193,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
187
193
  lastStationContextKeyRef.current = renderedStationContextKey;
188
194
  }
189
195
  }, [renderedStationContextKey, screen, selected]);
196
+ useEffect(() => {
197
+ const current = {
198
+ screen,
199
+ receiverStyle: library.settings.receiverStyle,
200
+ playbackState: playback.state,
201
+ playbackReady: playback.ready
202
+ };
203
+ if (shouldResetReceiverPulse(receiverPulseSnapshotRef.current, current)) {
204
+ setPulse(0);
205
+ }
206
+ receiverPulseSnapshotRef.current = current;
207
+ }, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
190
208
  useEffect(() => {
191
209
  if (!shouldAnimateReceiver(screen, playback) ||
192
210
  library.settings.reduceMotion ||
@@ -195,7 +213,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
195
213
  return;
196
214
  }
197
215
  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);
216
+ const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
199
217
  return () => clearInterval(timer);
200
218
  }, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
201
219
  useEffect(() => {
@@ -223,6 +241,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
223
241
  if (exploreMoveTimerRef.current) {
224
242
  clearTimeout(exploreMoveTimerRef.current);
225
243
  }
244
+ if (transientMessageTimerRef.current) {
245
+ clearTimeout(transientMessageTimerRef.current);
246
+ }
247
+ if (transientFooterMessageTimerRef.current) {
248
+ clearTimeout(transientFooterMessageTimerRef.current);
249
+ }
226
250
  }, []);
227
251
  useEffect(() => {
228
252
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
@@ -303,6 +327,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
303
327
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
304
328
  const remembered = selectedByScreenRef.current[next] ?? 0;
305
329
  const nextSelection = options.resetSelection ? 0 : remembered;
330
+ if (next === 'now-playing' && screenRef.current !== 'now-playing') {
331
+ setPulse(0);
332
+ }
306
333
  setScreen(next);
307
334
  setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
308
335
  if (options.clearMessage !== false) {
@@ -482,6 +509,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
482
509
  go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
483
510
  try {
484
511
  if (!settingsRef.current.enableNearbyLocation) {
512
+ if (stationContextsRef.current.nearby.stations.length > 0) {
513
+ setMessage('Nearby location lookup is off. Showing the last nearby station list.');
514
+ return;
515
+ }
485
516
  setStationContextFor('nearby', {
486
517
  title: 'Nearby',
487
518
  subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
@@ -492,6 +523,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
492
523
  const detected = location ?? (await providers.detectLocation());
493
524
  setLocation(detected);
494
525
  if (!detected) {
526
+ if (stationContextsRef.current.nearby.stations.length > 0) {
527
+ setMessage('Location detection is unavailable. Showing the last nearby station list.');
528
+ return;
529
+ }
495
530
  setStationContextFor('nearby', {
496
531
  title: 'Nearby',
497
532
  subtitle: 'Location detection was unavailable',
@@ -599,6 +634,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
599
634
  }
600
635
  }, [playStation, store]);
601
636
  playStationRef.current = playStation;
637
+ const showTransientFooterMessage = useCallback((nextMessage) => {
638
+ if (transientFooterMessageTimerRef.current) {
639
+ clearTimeout(transientFooterMessageTimerRef.current);
640
+ }
641
+ setFooterMessage(nextMessage);
642
+ transientFooterMessageTimerRef.current = setTimeout(() => {
643
+ setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
644
+ transientFooterMessageTimerRef.current = null;
645
+ }, VISUALIZER_MESSAGE_MS);
646
+ }, []);
602
647
  const toggleFavorite = useCallback((station) => {
603
648
  if (!station) {
604
649
  setMessage('Select or play a station before pressing f.');
@@ -606,12 +651,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
606
651
  }
607
652
  const wasFavorite = store.isFavorite(station);
608
653
  setLibrary(store.toggleFavorite(station));
609
- setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
654
+ const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
655
+ if (screenRef.current === 'library') {
656
+ showTransientFooterMessage(favoriteMessage);
657
+ }
658
+ else {
659
+ setMessage(favoriteMessage);
660
+ }
610
661
  if (!wasFavorite) {
611
662
  // Best-effort upvote back to the directory; never blocks favoriting.
612
663
  void providers.vote(station);
613
664
  }
614
- }, [providers, store]);
665
+ }, [providers, showTransientFooterMessage, store]);
615
666
  const showControlResult = useCallback((result) => {
616
667
  if (!result.ok && result.message) {
617
668
  setMessage(result.message);
@@ -669,16 +720,27 @@ export function App({ store: providedStore, providers: providedProviders }) {
669
720
  const togglePause = useCallback(() => {
670
721
  void player.togglePause().then(showControlResult);
671
722
  }, [player, showControlResult]);
723
+ const showTransientMessage = useCallback((nextMessage) => {
724
+ if (transientMessageTimerRef.current) {
725
+ clearTimeout(transientMessageTimerRef.current);
726
+ }
727
+ setMessage(nextMessage);
728
+ transientMessageTimerRef.current = setTimeout(() => {
729
+ setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
730
+ transientMessageTimerRef.current = null;
731
+ }, VISUALIZER_MESSAGE_MS);
732
+ }, []);
672
733
  const cycleDisplayColor = useCallback(() => {
673
734
  const theme = nextTheme(settingsRef.current.theme);
674
735
  updateSettings({ theme });
675
- setMessage(`Display color: ${theme}`);
676
- }, [updateSettings]);
736
+ showTransientMessage(`Display color: ${theme}`);
737
+ }, [showTransientMessage, updateSettings]);
677
738
  const cycleReceiverStyle = useCallback(() => {
678
739
  const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
740
+ setPulse(0);
679
741
  updateSettings({ receiverStyle });
680
- setMessage(`Receiver style: ${receiverStyle}`);
681
- }, [updateSettings]);
742
+ showTransientMessage(`Receiver style: ${receiverStyle}`);
743
+ }, [showTransientMessage, updateSettings]);
682
744
  const toggleRadioGarden = useCallback(() => {
683
745
  const enableRadioGarden = !settingsRef.current.enableRadioGarden;
684
746
  updateSettings({ enableRadioGarden });
@@ -987,7 +1049,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
987
1049
  playbackBackend: playback.backend,
988
1050
  screen
989
1051
  });
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) })] })] }) }));
1052
+ return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), _jsx(Box, { height: 1, children: message ? _jsx(Text, { color: themeAccent(theme), children: truncate(message, frameWidth) }) : null })] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(Text, { color: themeAccent(theme), children: footerMessage ? truncate(footerMessage, frameWidth) : ' ' }), playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }) }));
991
1053
  }
992
1054
  function buildLibraryStations(library) {
993
1055
  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,27 @@ export function stationContextKeyForScreen(screen) {
173
173
  export function shouldAnimateReceiver(screen, playback) {
174
174
  return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
175
175
  }
176
+ export function shouldToggleNearbyLocationShortcut(input, screen) {
177
+ return input === 'l' && (screen === 'home' || screen === 'nearby' || screen === 'settings');
178
+ }
179
+ export function nextReceiverPulse(pulse) {
180
+ return pulse + 1;
181
+ }
182
+ export function shouldResetReceiverPulse(previous, current) {
183
+ if (current.receiverStyle !== 'ultracode') {
184
+ return false;
185
+ }
186
+ const currentActive = current.screen === 'now-playing' &&
187
+ current.playbackState === 'playing' &&
188
+ current.playbackReady;
189
+ if (!currentActive) {
190
+ return false;
191
+ }
192
+ return !(previous?.screen === 'now-playing' &&
193
+ previous.receiverStyle === 'ultracode' &&
194
+ previous.playbackState === 'playing' &&
195
+ previous.playbackReady);
196
+ }
176
197
  export function isEditableInput(input, key) {
177
198
  return Boolean(key.backspace || key.delete || input);
178
199
  }
@@ -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,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  export function Menu({ items, selected, keyFor, render }) {
4
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === selected) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
4
+ const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
5
+ return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
5
6
  }
6
7
  export function Pointer({ active }) {
7
8
  return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
@@ -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,11 +1,11 @@
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 }) {
7
7
  const accent = themeAccent(theme);
8
- const { panel: panelBackground, ascii } = useDisplay();
8
+ const { app: background, ascii } = useDisplay();
9
9
  const box = ascii
10
10
  ? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
11
11
  : { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
@@ -20,7 +20,7 @@ export function TopTabs({ tabs, active, theme, width, rightLabel }) {
20
20
  const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
21
21
  const visibleTabsWidth = tabsWidth(visibleTabs);
22
22
  const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
23
- return (_jsxs(Box, { flexDirection: "column", backgroundColor: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: panelBackground, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: "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: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
24
24
  }
25
25
  function fitTabs(tabs, active, maxWidth) {
26
26
  const all = tabs.map(tab => ({ type: 'tab', tab }));
package/dist/ui/layout.js CHANGED
@@ -7,6 +7,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
7
7
  const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
8
8
  const mapMode = safeColumns >= 88 && contentRows >= 24 ? 'full' : 'compact';
9
9
  const stationRows = clamp(contentRows - 6, 1, 48);
10
+ const countryRows = clamp(contentRows - 5, 1, 64);
10
11
  return {
11
12
  columns: safeColumns,
12
13
  rows: safeRows,
@@ -14,7 +15,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
14
15
  topRows,
15
16
  contentRows,
16
17
  stationRows: compact ? 0 : stationRows,
17
- countryRows: compact ? 0 : Math.max(1, contentRows - 4),
18
+ countryRows: compact ? 0 : countryRows,
18
19
  mapCountryRows: compact ? 0 : Math.max(1, contentRows - (mapMode === 'full' ? 25 : 14)),
19
20
  mapMode,
20
21
  receiverWidth: compact ? safeColumns : Math.max(62, safeColumns - 4),
@@ -7,7 +7,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
7
7
  return `COMMAND :${commandText}`;
8
8
  }
9
9
  if (screen === 'home') {
10
- return '↑/↓ move · Enter open · number jump · : command';
10
+ return '↑/↓ move · Enter open · number jump · l location · : command';
11
11
  }
12
12
  if (screen === 'search' && editingSearch) {
13
13
  return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
@@ -27,9 +27,10 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
27
27
  if (screen === 'explore') {
28
28
  return 'Click map · WASD fine move · Shift+WASD jump · ↑/↓ station · Enter tune · f favorite · b home';
29
29
  }
30
- if (screen === 'nearby' ||
31
- screen === 'stations' ||
32
- screen === 'library') {
30
+ if (screen === 'nearby') {
31
+ return '↑/↓ or n/p move · Enter tune · f favorite · l location · [/] page · b home';
32
+ }
33
+ if (screen === 'stations' || screen === 'library') {
33
34
  return '↑/↓ or n/p move · Enter tune · f favorite · [/] page · b home';
34
35
  }
35
36
  if (screen === 'now-playing') {
@@ -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
  }