@ciphore/radiocli 0.1.4 → 0.1.6

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/dist/ui/App.js CHANGED
@@ -6,7 +6,8 @@ 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 { appBackground, nextReceiverStyle, nextTheme, panelBackground, textDim, themeAccent } from './theme.js';
9
+ import { nextReceiverStyle, nextTheme, textDim, themeAccent } from './theme.js';
10
+ import { DisplayContext, resolveDisplayMode } from './display-context.js';
10
11
  import { homeItems, settingsItems } from './screen-items.js';
11
12
  import { AppContent } from './AppContent.js';
12
13
  import { TopTabs } from './components/TopTabs.js';
@@ -20,11 +21,18 @@ import { useCommandExecutor } from './use-command-executor.js';
20
21
  import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
21
22
  import { isAirPlayBackendAvailable } from './airplay-settings.js';
22
23
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
24
+ import { copyToClipboard, openExternal } from './system-actions.js';
23
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';
24
26
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
25
27
  const LIVE_RECEIVER_PULSE_MS = 80;
26
28
  const AMBIENT_RECEIVER_PULSE_MS = 140;
27
29
  const LOADING_SPINNER_MS = 120;
30
+ const settingToggleLabel = {
31
+ resumeOnLaunch: 'Resume on launch',
32
+ transparentBackground: 'Transparent background',
33
+ asciiMode: 'ASCII-safe display',
34
+ reduceMotion: 'Reduce motion'
35
+ };
28
36
  export function App({ store: providedStore, providers: providedProviders }) {
29
37
  const { exit } = useApp();
30
38
  const { stdin } = useStdin();
@@ -74,10 +82,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
74
82
  const stationContextsRef = useRef(stationContexts);
75
83
  const lastStationContextKeyRef = useRef('explore');
76
84
  const lastSubmittedSearchRef = useRef('');
85
+ const searchHistoryRef = useRef({ cursor: -1, draft: '' });
77
86
  const exploreCursorRef = useRef(exploreCursor);
78
87
  const exploreRequestRef = useRef(0);
79
88
  const exploreMoveTimerRef = useRef(null);
80
89
  const theme = library.settings.theme;
90
+ const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
81
91
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
82
92
  const diagnostics = player.diagnostics();
83
93
  const filterLabel = formatFilterLabel(filters);
@@ -124,7 +134,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
124
134
  stats: 1,
125
135
  'airplay-settings': 0,
126
136
  'airplay-code': 1,
127
- settings: settingsItems.length
137
+ settings: settingsItems.length,
138
+ help: 0
128
139
  });
129
140
  itemCountsRef.current = {
130
141
  home: homeItems.length,
@@ -139,7 +150,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
139
150
  stats: 1,
140
151
  'airplay-settings': availableAirPlayDevices.length,
141
152
  'airplay-code': 1,
142
- settings: settingsItems.length
153
+ settings: settingsItems.length,
154
+ help: 0
143
155
  };
144
156
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
145
157
  displayStationsRef.current = displayStations;
@@ -151,7 +163,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
151
163
  const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
152
164
  const frameWidth = Math.max(40, layout.columns - 2);
153
165
  useEffect(() => player.onChange(setPlayback), [player]);
154
- useEffect(() => player.onMetadata(setNowPlaying), [player]);
166
+ const playingStationRef = useRef(null);
167
+ playingStationRef.current = playingStation;
168
+ useEffect(() => player.onMetadata(metadata => {
169
+ setNowPlaying(metadata);
170
+ const station = playingStationRef.current;
171
+ if (station && metadata.title) {
172
+ setLibrary(store.recordTrack(station, metadata.title));
173
+ }
174
+ }), [player, store]);
155
175
  useEffect(() => {
156
176
  if (screen !== 'explore' || layout.compact || !stdout.isTTY) {
157
177
  return;
@@ -169,6 +189,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
169
189
  }, [renderedStationContextKey, screen, selected]);
170
190
  useEffect(() => {
171
191
  if (!shouldAnimateReceiver(screen, playback) ||
192
+ library.settings.reduceMotion ||
172
193
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
173
194
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
174
195
  return;
@@ -176,9 +197,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
176
197
  const intervalMs = LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle) ? LIVE_RECEIVER_PULSE_MS : AMBIENT_RECEIVER_PULSE_MS;
177
198
  const timer = setInterval(() => setPulse(value => (value + 1) % 240), intervalMs);
178
199
  return () => clearInterval(timer);
179
- }, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
200
+ }, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
180
201
  useEffect(() => {
181
202
  if (playback.state !== 'loading' ||
203
+ library.settings.reduceMotion ||
182
204
  process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
183
205
  process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
184
206
  setSpinnerFrame(0);
@@ -186,7 +208,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
186
208
  }
187
209
  const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
188
210
  return () => clearInterval(timer);
189
- }, [playback.state]);
211
+ }, [library.settings.reduceMotion, playback.state]);
190
212
  useEffect(() => {
191
213
  if ((screen === 'countries' || screen === 'map') && countries.length === 0 && !loadingCountries) {
192
214
  setLoadingCountries(true);
@@ -420,6 +442,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
420
442
  });
421
443
  selectedByScreenRef.current.search = 0;
422
444
  lastSubmittedSearchRef.current = query.trim();
445
+ setLibrary(store.addSearch(query));
446
+ searchHistoryRef.current = { cursor: -1, draft: '' };
423
447
  setSelected(0);
424
448
  setEditingSearch(true);
425
449
  }
@@ -429,7 +453,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
429
453
  finally {
430
454
  setLoadingStations(false);
431
455
  }
432
- }, [filters, providers, searchQuery, setStationContextFor]);
456
+ }, [filters, providers, searchQuery, setStationContextFor, store]);
457
+ const recallSearchHistory = useCallback((direction) => {
458
+ const history = store.snapshot().searchHistory;
459
+ if (history.length === 0) {
460
+ return;
461
+ }
462
+ const state = searchHistoryRef.current;
463
+ if (direction === 'older') {
464
+ if (state.cursor === -1) {
465
+ state.draft = searchQuery;
466
+ }
467
+ state.cursor = Math.min(history.length - 1, state.cursor + 1);
468
+ setSearchQuery(history[state.cursor] ?? '');
469
+ }
470
+ else if (state.cursor <= 0) {
471
+ state.cursor = -1;
472
+ setSearchQuery(state.draft);
473
+ }
474
+ else {
475
+ state.cursor -= 1;
476
+ setSearchQuery(history[state.cursor] ?? '');
477
+ }
478
+ }, [searchQuery, store]);
433
479
  const loadNearby = useCallback(async () => {
434
480
  setLoadingStations(true);
435
481
  setMessage(null);
@@ -536,6 +582,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
536
582
  setMessage(message);
537
583
  }
538
584
  }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
585
+ // Resume the most recent station on launch when opted in, like a radio
586
+ // powering back on to its last frequency. Runs once and never auto-navigates.
587
+ const didResumeRef = useRef(false);
588
+ useEffect(() => {
589
+ if (didResumeRef.current) {
590
+ return;
591
+ }
592
+ didResumeRef.current = true;
593
+ if (!settingsRef.current.resumeOnLaunch) {
594
+ return;
595
+ }
596
+ const last = store.snapshot().recent[0]?.station;
597
+ if (last) {
598
+ void playStation(last);
599
+ }
600
+ }, [playStation, store]);
539
601
  playStationRef.current = playStation;
540
602
  const toggleFavorite = useCallback((station) => {
541
603
  if (!station) {
@@ -545,12 +607,40 @@ export function App({ store: providedStore, providers: providedProviders }) {
545
607
  const wasFavorite = store.isFavorite(station);
546
608
  setLibrary(store.toggleFavorite(station));
547
609
  setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
548
- }, [store]);
610
+ if (!wasFavorite) {
611
+ // Best-effort upvote back to the directory; never blocks favoriting.
612
+ void providers.vote(station);
613
+ }
614
+ }, [providers, store]);
549
615
  const showControlResult = useCallback((result) => {
550
616
  if (!result.ok && result.message) {
551
617
  setMessage(result.message);
552
618
  }
553
619
  }, []);
620
+ const openStationHomepage = useCallback((station) => {
621
+ if (!station?.homepage) {
622
+ setMessage('This station has no homepage.');
623
+ return;
624
+ }
625
+ openExternal(station.homepage);
626
+ setMessage(`Opening homepage: ${station.name}`);
627
+ }, []);
628
+ const copyStationUrl = useCallback(async (station) => {
629
+ if (!station) {
630
+ setMessage('Select or play a station first.');
631
+ return;
632
+ }
633
+ let url = station.streamUrl;
634
+ if (!url) {
635
+ url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
636
+ }
637
+ if (!url) {
638
+ setMessage(`No stream URL available for ${station.name}.`);
639
+ return;
640
+ }
641
+ const copied = copyToClipboard(url);
642
+ setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`);
643
+ }, [providers]);
554
644
  const submitAirPlayCode = useCallback((code) => {
555
645
  const result = player.submitAirPlayPasscode(code);
556
646
  if (result.ok) {
@@ -695,6 +785,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
695
785
  updateSettings({ skipBrokenStreams });
696
786
  setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
697
787
  }, [updateSettings]);
788
+ const toggleSetting = useCallback((key) => {
789
+ const next = !settingsRef.current[key];
790
+ updateSettings({ [key]: next });
791
+ setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
792
+ }, [updateSettings]);
698
793
  const cycleSleepTimer = useCallback(() => {
699
794
  const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
700
795
  const next = nextSleepTimerMinutes(currentMinutes);
@@ -798,6 +893,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
798
893
  adjustVolume,
799
894
  airPlayCode,
800
895
  canEnterAirPlayCode,
896
+ copyStationUrl,
897
+ openStationHomepage,
801
898
  beginLearningTransportKey,
802
899
  capturingTransportAction,
803
900
  commandMode,
@@ -823,6 +920,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
823
920
  playStation,
824
921
  player,
825
922
  playingStation,
923
+ recallSearchHistory,
826
924
  moveExploreCursor: moveExploreMapCursor,
827
925
  moveExploreCursorToCell: moveExploreMapCursorToCell,
828
926
  refreshProviderHealth,
@@ -855,6 +953,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
855
953
  toggleFavorite,
856
954
  toggleMute,
857
955
  togglePause,
956
+ toggleSetting,
858
957
  toggleNearbyLocation,
859
958
  toggleRadioGarden,
860
959
  toggleSkipBrokenStreams
@@ -864,10 +963,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
864
963
  }
865
964
  const hasTopTabs = !layout.compact;
866
965
  const globalFooter = playback.backend === 'ffplay'
867
- ? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · q quit'
966
+ ? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · ? help · q quit'
868
967
  : playback.backend === 'airplay'
869
- ? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · q quit'
870
- : '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
968
+ ? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · ? help · q quit'
969
+ : '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit';
871
970
  const playbackFooter = playbackFooterText({
872
971
  station: playingStation,
873
972
  playback,
@@ -888,7 +987,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
888
987
  playbackBackend: playback.backend,
889
988
  screen
890
989
  });
891
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: appBackground, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: appBackground, 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: appBackground, 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: panelBackground, 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) })] })] }));
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) })] })] }) }));
892
991
  }
893
992
  function buildLibraryStations(library) {
894
993
  const stations = [];
@@ -937,7 +1036,7 @@ function formatDistanceKm(distanceKm) {
937
1036
  }
938
1037
  function nextAvailablePlaybackBackend(current, backends) {
939
1038
  const options = ['auto'];
940
- for (const backend of ['mpv', 'ffplay', 'airplay']) {
1039
+ for (const backend of ['mpv', 'ffplay', 'vlc', 'airplay']) {
941
1040
  if (backends.includes(backend)) {
942
1041
  options.push(backend);
943
1042
  }
@@ -962,6 +1061,9 @@ function preferredLocalPlaybackBackend(backends) {
962
1061
  if (backends.includes('ffplay')) {
963
1062
  return 'ffplay';
964
1063
  }
1064
+ if (backends.includes('vlc')) {
1065
+ return 'vlc';
1066
+ }
965
1067
  return null;
966
1068
  }
967
1069
  function audioOutputSwitchLabel(output, backends) {
@@ -10,6 +10,7 @@ import { StationScreen } from './screens/StationScreen.js';
10
10
  import { NowPlayingScreen } from './screens/NowPlayingScreen.js';
11
11
  import { StatsScreen } from './screens/StatsScreen.js';
12
12
  import { SettingsScreen } from './screens/SettingsScreen.js';
13
+ import { HelpScreen } from './screens/HelpScreen.js';
13
14
  import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
14
15
  import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
16
  import { selectedAirPlayDevice } from './airplay-settings.js';
@@ -37,7 +38,7 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
37
38
  return (_jsx(StationScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, pageSize: layout.stationRows, width: frameWidth }));
38
39
  }
39
40
  if (screen === 'now-playing') {
40
- return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics, sleepLabel: sleepLabel, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, width: layout.receiverWidth, height: layout.receiverRows }));
41
+ return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics, sleepLabel: sleepLabel, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, trackHistory: library.trackHistory, width: layout.receiverWidth, height: layout.receiverRows }));
41
42
  }
42
43
  if (screen === 'stats') {
43
44
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
@@ -51,5 +52,8 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
51
52
  if (screen === 'airplay-code') {
52
53
  return (_jsx(AirPlayCodeScreen, { code: airPlayCode, playback: playback, selectedDevice: selectedAirPlayDevice(library.settings, airPlayDevices), theme: theme, width: frameWidth }));
53
54
  }
55
+ if (screen === 'help') {
56
+ return _jsx(HelpScreen, { theme: theme, width: frameWidth });
57
+ }
54
58
  return _jsx(Text, { children: "Unknown screen." });
55
59
  }
@@ -6,7 +6,7 @@ const emptyMediaKeyBindings = {
6
6
  };
7
7
  const mediaTransportActions = ['previous', 'playPause', 'next'];
8
8
  const sleepTimerOptions = [null, 15, 30, 60];
9
- const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'airplay'];
9
+ const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'vlc', 'airplay'];
10
10
  export const defaultExploreCursor = {
11
11
  latitude: 48.8566,
12
12
  longitude: 2.3522
@@ -0,0 +1,84 @@
1
+ // Map a single Unicode code point to a width-1 ASCII approximation. Used by the
2
+ // ASCII-safe display mode so terminals/fonts without braille, block, or
3
+ // box-drawing glyphs still render a legible receiver and chrome.
4
+ const shadeRamp = ' .:-=+*#%@';
5
+ function asciiGlyph(code) {
6
+ // Braille patterns: approximate by how many dots are raised (0-8).
7
+ if (code >= 0x2800 && code <= 0x28ff) {
8
+ const dots = popcount(code - 0x2800);
9
+ const index = Math.round((dots / 8) * (shadeRamp.length - 1));
10
+ return shadeRamp[index] ?? '#';
11
+ }
12
+ // Light/medium/dark shade blocks.
13
+ if (code === 0x2591) {
14
+ return '.';
15
+ }
16
+ if (code === 0x2592) {
17
+ return ':';
18
+ }
19
+ if (code === 0x2593) {
20
+ return '#';
21
+ }
22
+ // Block elements (full, partial, half blocks).
23
+ if (code >= 0x2580 && code <= 0x259f) {
24
+ return '#';
25
+ }
26
+ // Box drawing.
27
+ if (code >= 0x2500 && code <= 0x257f) {
28
+ if (code === 0x2500 || code === 0x2501 || code === 0x2504 || code === 0x2505) {
29
+ return '-';
30
+ }
31
+ if (code === 0x2502 || code === 0x2503 || code === 0x2506 || code === 0x2507) {
32
+ return '|';
33
+ }
34
+ return '+';
35
+ }
36
+ // Common standalone marks.
37
+ // Density-style marks: keep a low-to-high ramp so map textures stay legible.
38
+ if (code === 0x00b7) {
39
+ return '.'; // middle dot ·
40
+ }
41
+ if (code === 0x2026) {
42
+ return '.'; // ellipsis …
43
+ }
44
+ if (code === 0x2022) {
45
+ return 'o'; // bullet •
46
+ }
47
+ if (code === 0x25cf) {
48
+ return '@'; // black circle ●
49
+ }
50
+ if (code === 0x25cb || code === 0x25aa || code === 0x25a0) {
51
+ return '*';
52
+ }
53
+ if (code === 0x2605) {
54
+ return '*'; // ★
55
+ }
56
+ if (code === 0x2606) {
57
+ return 'o'; // ☆
58
+ }
59
+ if (code === 0x2014 || code === 0x2013) {
60
+ return '-';
61
+ }
62
+ // Drop zero-width and combining marks entirely so they do not skew widths.
63
+ if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) {
64
+ return '';
65
+ }
66
+ return '*';
67
+ }
68
+ function popcount(value) {
69
+ let count = 0;
70
+ let remaining = value;
71
+ while (remaining > 0) {
72
+ count += remaining & 1;
73
+ remaining >>>= 1;
74
+ }
75
+ return count;
76
+ }
77
+ export function toAsciiSafe(value) {
78
+ let out = '';
79
+ for (const char of value) {
80
+ const code = char.codePointAt(0) ?? 0;
81
+ out += code < 0x80 ? char : asciiGlyph(code);
82
+ }
83
+ return out;
84
+ }
@@ -9,6 +9,9 @@ export function audioOutputLabel(output) {
9
9
  if (output === 'ffplay') {
10
10
  return 'This device (ffplay fallback)';
11
11
  }
12
+ if (output === 'vlc') {
13
+ return 'This device (VLC fallback)';
14
+ }
12
15
  if (output === 'airplay') {
13
16
  return 'AirPlay';
14
17
  }
@@ -21,7 +24,7 @@ export function resolvedAudioOutput(output, backends) {
21
24
  if (output !== 'auto') {
22
25
  return backends.includes(output) ? output : null;
23
26
  }
24
- for (const backend of ['mpv', 'ffplay']) {
27
+ for (const backend of ['mpv', 'ffplay', 'vlc']) {
25
28
  if (backends.includes(backend)) {
26
29
  return backend;
27
30
  }
@@ -1,15 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { textDim, themeAccent } from '../theme.js';
4
+ import { useDisplay } from '../display-context.js';
5
+ import { toAsciiSafe } from '../ascii.js';
4
6
  import { truncate } from '../format.js';
5
7
  // One title treatment shared by every screen: an accented, bold title trailed by
6
8
  // a dim rule that fills the frame, with optional right-aligned meta and a muted
7
9
  // subtitle below. Renders one row (plus one for the subtitle when present).
8
10
  export function ScreenHeader({ title, width, theme, subtitle, right }) {
9
11
  const accent = themeAccent(theme);
12
+ const { ascii } = useDisplay();
13
+ const a = (value) => (ascii ? toAsciiSafe(value) : value);
10
14
  const safeTitle = truncate(title, Math.max(4, width - 4));
11
15
  const rightText = right ? ` ${right}` : '';
12
16
  const ruleWidth = Math.max(1, width - safeTitle.length - rightText.length - 2);
13
- const rule = '─'.repeat(ruleWidth);
14
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: safeTitle }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: "gray", children: rightText }) : null] }), subtitle ? _jsx(Text, { color: "gray", children: truncate(subtitle, width) }) : null] }));
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] }));
15
19
  }
@@ -1,9 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { panelBackground, panelBorder, themeAccent } from '../theme.js';
3
+ import { panelBorder, themeAccent } from '../theme.js';
4
+ import { useDisplay } from '../display-context.js';
4
5
  import { truncate } from '../format.js';
5
6
  export function TopTabs({ tabs, active, theme, width, rightLabel }) {
6
7
  const accent = themeAccent(theme);
8
+ const { panel: panelBackground, ascii } = useDisplay();
9
+ const box = ascii
10
+ ? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
11
+ : { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
7
12
  const brand = 'RADIOCLI';
8
13
  const rightText = ` ${rightLabel} `;
9
14
  const bodyWidth = Math.max(1, width - 4);
@@ -15,7 +20,7 @@ export function TopTabs({ tabs, active, theme, width, rightLabel }) {
15
20
  const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
16
21
  const visibleTabsWidth = tabsWidth(visibleTabs);
17
22
  const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
18
- return (_jsxs(Box, { flexDirection: "column", backgroundColor: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, children: [_jsx(Text, { color: panelBorder, children: "\u250C " }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", '─'.repeat(titleRuleWidth), "\u2510"] })] }), _jsxs(Text, { backgroundColor: panelBackground, children: [_jsx(Text, { color: panelBorder, children: "\u2502 " }), 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 ? _jsx(Text, { color: "gray", children: " \u2502 " }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: "gray", children: rightText }) : null, _jsx(Text, { color: panelBorder, children: " \u2502" })] }), _jsxs(Text, { backgroundColor: panelBackground, color: panelBorder, children: ["\u2514", '─'.repeat(Math.max(0, width - 2)), "\u2518"] })] }));
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] })] }));
19
24
  }
20
25
  function fitTabs(tabs, active, maxWidth) {
21
26
  const all = tabs.map(tab => ({ type: 'tab', tab }));
@@ -0,0 +1,25 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { appBackground, panelBackground } from './theme.js';
3
+ const opaqueBackgrounds = { app: appBackground, panel: panelBackground };
4
+ const transparentBackgrounds = { app: undefined, panel: undefined };
5
+ // Respect the NO_COLOR convention (https://no-color.org): any non-empty value
6
+ // opts the user out of our forced colors, including the dark panel fills.
7
+ export function noColorRequested(env = process.env) {
8
+ return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0;
9
+ }
10
+ // Resolve the effective display mode from the user's settings and the NO_COLOR
11
+ // environment so light terminals keep their own background.
12
+ export function resolveDisplayMode(settings, env = process.env) {
13
+ const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds;
14
+ return { ...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion) };
15
+ }
16
+ const defaultMode = { ...opaqueBackgrounds, ascii: false, reduceMotion: false };
17
+ export const DisplayContext = createContext(defaultMode);
18
+ export function useDisplay() {
19
+ return useContext(DisplayContext);
20
+ }
21
+ // Ink's rounded/single borders are Unicode box-drawing; "classic" is ASCII
22
+ // (+-|) and renders on terminals and fonts without box-drawing glyphs.
23
+ export function panelBorderStyle(ascii, base = 'round') {
24
+ return ascii ? 'classic' : base;
25
+ }
@@ -0,0 +1,116 @@
1
+ export const keyHelpSections = [
2
+ {
3
+ title: 'Navigation',
4
+ entries: [
5
+ { keys: '↑/↓ or n/p', description: 'Move selection' },
6
+ { keys: '←/→ or Tab', description: 'Switch tabs' },
7
+ { keys: '[ / ]', description: 'Page selection by 10' },
8
+ { keys: '1-9, 0', description: 'Jump to home menu item' },
9
+ { keys: 'Enter', description: 'Open / tune the selection' },
10
+ { keys: 'b or Esc', description: 'Back to home' },
11
+ { keys: ': ', description: 'Command palette' },
12
+ { keys: '?', description: 'Toggle this help' },
13
+ { keys: 'q or Ctrl+C', description: 'Quit' }
14
+ ]
15
+ },
16
+ {
17
+ title: 'Playback',
18
+ entries: [
19
+ { keys: 'space or F8', description: 'Pause / resume' },
20
+ { keys: ', / . or F7/F9', description: 'Previous / next station' },
21
+ { keys: '+ / -', description: 'Volume up / down' },
22
+ { keys: 'm', description: 'Mute / unmute' },
23
+ { keys: 'f', description: 'Favorite the station' },
24
+ { keys: 'O', description: 'Open station homepage' },
25
+ { keys: 'y', description: 'Copy stream URL' },
26
+ { keys: 's', description: 'Sleep timer (Now Playing)' },
27
+ { keys: 'd', description: 'Diagnostics + recent tracks (Now Playing)' }
28
+ ]
29
+ },
30
+ {
31
+ title: 'Display & discovery',
32
+ entries: [
33
+ { keys: 't', description: 'Cycle display color' },
34
+ { keys: 'v', description: 'Cycle receiver style' },
35
+ { keys: 'o', description: 'Cycle audio output' },
36
+ { keys: '/', description: 'Edit search / country filter' },
37
+ { keys: 'w', description: 'Toggle country list / world map' },
38
+ { keys: 'WASD', description: 'Move the Explore map cursor' },
39
+ { keys: 'r', description: 'Refresh provider health' }
40
+ ]
41
+ }
42
+ ];
43
+ export const commandHelp = [
44
+ { name: 'search', args: '<query>', description: 'Search stations (alias :s)' },
45
+ { name: 'country', args: '<name|code>', description: 'Open a country (alias :c)' },
46
+ { name: 'codec', args: '<codec|any>', description: 'Filter by codec' },
47
+ { name: 'language', args: '<lang|any>', description: 'Filter by language (alias :lang)' },
48
+ { name: 'bitrate', args: '<kbps>', description: 'Minimum bitrate filter' },
49
+ { name: 'clear', description: 'Clear search filters' },
50
+ { name: 'volume', args: '<0-100>', description: 'Set volume (alias :vol)' },
51
+ { name: 'mute', description: 'Toggle mute' },
52
+ { name: 'sleep', args: '<minutes>', description: 'Set or clear the sleep timer' },
53
+ { name: 'timeout', args: '<seconds>', description: 'Tune timeout (3-45)' },
54
+ { name: 'skip', args: '<on|off>', description: 'Skip broken streams' },
55
+ { name: 'location', args: '<on|off>', description: 'Nearby location lookup' },
56
+ { name: 'doctor', description: 'Show playback backend status' },
57
+ { name: 'resume', args: '<on|off>', description: 'Resume last station on launch' },
58
+ { name: 'ascii', args: '<on|off>', description: 'ASCII-safe display' },
59
+ { name: 'motion', args: '<on|off>', description: 'Reduce motion' },
60
+ { name: 'background', args: '<on|off>', description: 'Transparent background' },
61
+ { name: 'airplay', description: 'Open AirPlay settings' },
62
+ { name: 'learn', args: '<previous|play|next>', description: 'Learn a media key' },
63
+ { name: 'keys', args: '[reset]', description: 'Show or reset learned media keys' },
64
+ { name: 'favorite', description: 'Favorite the current station (alias :fav)' },
65
+ { name: 'library', description: 'Open the library' },
66
+ { name: 'map', description: 'Open the world map' },
67
+ { name: 'stats', description: 'Open listening stats' },
68
+ { name: 'settings', description: 'Open settings' },
69
+ { name: 'stop', description: 'Stop playback' },
70
+ { name: 'help', description: 'Open this help' }
71
+ ];
72
+ // Primary command names plus the aliases the executor accepts, used for
73
+ // command-mode tab completion.
74
+ export const commandNames = [
75
+ 's',
76
+ 'c',
77
+ 'lang',
78
+ 'vol',
79
+ 'fav',
80
+ 'recent',
81
+ 'favorites',
82
+ 'imports',
83
+ 'bind',
84
+ 'key',
85
+ 'airplay-code',
86
+ 'airplay-settings',
87
+ ...commandHelp.map(entry => entry.name)
88
+ ].sort((a, b) => a.localeCompare(b));
89
+ // Complete a partial command name to the longest shared prefix of its matches.
90
+ // Returns the original value when there is no match.
91
+ export function completeCommand(partial, names = commandNames) {
92
+ const lower = partial.toLowerCase();
93
+ const matches = names.filter(name => name.startsWith(lower));
94
+ if (matches.length === 0) {
95
+ return partial;
96
+ }
97
+ if (matches.length === 1) {
98
+ return matches[0];
99
+ }
100
+ return longestCommonPrefix(matches) || partial;
101
+ }
102
+ function longestCommonPrefix(values) {
103
+ if (values.length === 0) {
104
+ return '';
105
+ }
106
+ let prefix = values[0];
107
+ for (const value of values.slice(1)) {
108
+ while (!value.startsWith(prefix)) {
109
+ prefix = prefix.slice(0, -1);
110
+ if (!prefix) {
111
+ return '';
112
+ }
113
+ }
114
+ }
115
+ return prefix;
116
+ }
@@ -10,7 +10,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
10
10
  return '↑/↓ move · Enter open · number jump · : command';
11
11
  }
12
12
  if (screen === 'search' && editingSearch) {
13
- return 'Type query · Backspace edit · Enter search/tune · Esc finish';
13
+ return 'Type query · ↑/↓ history · Enter search/tune · Esc finish';
14
14
  }
15
15
  if (screen === 'search') {
16
16
  return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b home';
@@ -55,5 +55,8 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
55
55
  if (screen === 'stats') {
56
56
  return 'b home';
57
57
  }
58
+ if (screen === 'help') {
59
+ return '? or b close · : command';
60
+ }
58
61
  return ': command';
59
62
  }