@ciphore/radiocli 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -2
- package/dist/activity/stats.js +4 -3
- package/dist/storage/store.js +3 -0
- package/dist/types.js +1 -1
- package/dist/ui/App.js +37 -8
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +27 -0
- package/dist/ui/components/Logo.js +2 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +4 -4
- package/dist/ui/components/TopTabs.js +2 -2
- package/dist/ui/page-footer.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +3 -3
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -4
- package/dist/ui/screens/CountriesScreen.js +2 -2
- package/dist/ui/screens/ExploreScreen.js +2 -2
- package/dist/ui/screens/HelpScreen.js +2 -2
- package/dist/ui/screens/HomeScreen.js +2 -2
- package/dist/ui/screens/MapScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +5 -5
- package/dist/ui/screens/SearchScreen.js +2 -2
- package/dist/ui/screens/SettingsScreen.js +3 -3
- package/dist/ui/screens/StationScreen.js +2 -1
- package/dist/ui/screens/StatsScreen.js +112 -32
- package/dist/ui/screens/screen-render.test.js +69 -0
- package/dist/ui/theme.js +6 -3
- package/dist/ui/use-app-input.js +15 -7
- package/dist/ui/visualizers/receiver-visualizers.js +94 -75
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.8] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Replaced the classic equalizer receiver style with Ultracode, a centered
|
|
15
|
+
color-ripple display that follows the active display color; saved `equalizer`
|
|
16
|
+
preferences migrate to `ultracode`.
|
|
17
|
+
- The listening stats activity graph now renders as a calendar-year grid with
|
|
18
|
+
larger contribution cells and clearer month labels when the terminal has room.
|
|
19
|
+
- Muted UI text now uses truecolor neutrals instead of terminal `gray`, making
|
|
20
|
+
secondary labels more readable across dark terminal palettes.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Stale continuous listening sessions are capped before splitting them across
|
|
25
|
+
days so one long-unclosed playback session cannot dominate listening totals.
|
|
26
|
+
- Receiver visualizer pulses now reset when an active Ultracode view opens but
|
|
27
|
+
keep advancing smoothly during normal playback.
|
|
28
|
+
|
|
29
|
+
## [0.1.7] - 2026-06-29
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Search results can now be navigated with `Up` / `Down` while editing the
|
|
34
|
+
search query; search-history recall remains available with
|
|
35
|
+
`Ctrl+Up` / `Ctrl+Down`.
|
|
36
|
+
- The listening stats activity heatmap now renders day cells as square markers
|
|
37
|
+
and uses a capped contribution scale so one unusually long listening day does
|
|
38
|
+
not flatten the rest of the Less/More color range.
|
|
39
|
+
|
|
10
40
|
## [0.1.6] - 2026-06-17
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -146,6 +176,8 @@ Initial public release.
|
|
|
146
176
|
|
|
147
177
|
[0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
|
|
148
178
|
[0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
|
|
179
|
+
[0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
|
|
180
|
+
[0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
|
|
149
181
|
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
150
182
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
151
183
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Live public radio from around the world
|
|
|
82
82
|
|
|
83
83
|
The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
|
|
84
84
|
|
|
85
|
-
- **Classic receiver** —
|
|
85
|
+
- **Classic receiver** — ultracode ripple, LEDs, and goniometer.
|
|
86
86
|
- **High-resolution braille** — smooth waveform, radial EQ, spectrogram, nebula, silk, ripple tank, phyllotaxis, harmonograph, bloom bars, moiré, galaxy, caustics.
|
|
87
87
|
- **Generative & motion** — matrix, hologram, spinning ASCII cube, generated fire, fireworks, plasma, spinning donut, starfield, Lorenz attractor, Barnsley fern, Chladni plate, rotating tesseract, torus knot, fractal tree, Julia sets, and lava-lamp motion.
|
|
88
88
|
|
|
@@ -279,7 +279,7 @@ Page-specific footer controls:
|
|
|
279
279
|
| Screen | Controls |
|
|
280
280
|
| --- | --- |
|
|
281
281
|
| Home | `↑` / `↓` move, `Enter` open, number jump, `:` command |
|
|
282
|
-
| Search input | type query, `Backspace` edit, `Enter` search or tune, `Esc` finish |
|
|
282
|
+
| Search input | type query, `Backspace` edit, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Esc` finish |
|
|
283
283
|
| Search results | `/` edit query, `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home |
|
|
284
284
|
| Explore | click map, `WASD` fine move, `Shift+WASD` jump, `↑` / `↓` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
|
|
285
285
|
| Countries | `/` filter, `↑` / `↓` move, `Enter` open stations, `w` map, `b` home |
|
package/dist/activity/stats.js
CHANGED
|
@@ -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) {
|
package/dist/storage/store.js
CHANGED
|
@@ -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
|
-
'
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 }),
|
|
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 = [];
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -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:
|
|
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 });
|
package/dist/ui/app-state.js
CHANGED
|
@@ -52,6 +52,15 @@ export const topTabs = [
|
|
|
52
52
|
export function clamp(value, max) {
|
|
53
53
|
return Math.min(Math.max(value, 0), Math.max(max, 0));
|
|
54
54
|
}
|
|
55
|
+
export function searchEditingArrowAction(key, hasResults) {
|
|
56
|
+
if (key.upArrow) {
|
|
57
|
+
return key.ctrl || !hasResults ? 'history-older' : 'select-previous';
|
|
58
|
+
}
|
|
59
|
+
if (key.downArrow) {
|
|
60
|
+
return key.ctrl || !hasResults ? 'history-newer' : 'select-next';
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
55
64
|
export function clampVolume(value) {
|
|
56
65
|
return Math.min(100, Math.max(0, Math.round(value)));
|
|
57
66
|
}
|
|
@@ -164,6 +173,24 @@ export function stationContextKeyForScreen(screen) {
|
|
|
164
173
|
export function shouldAnimateReceiver(screen, playback) {
|
|
165
174
|
return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
|
|
166
175
|
}
|
|
176
|
+
export function nextReceiverPulse(pulse) {
|
|
177
|
+
return pulse + 1;
|
|
178
|
+
}
|
|
179
|
+
export function shouldResetReceiverPulse(previous, current) {
|
|
180
|
+
if (current.receiverStyle !== 'ultracode') {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const currentActive = current.screen === 'now-playing' &&
|
|
184
|
+
current.playbackState === 'playing' &&
|
|
185
|
+
current.playbackReady;
|
|
186
|
+
if (!currentActive) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return !(previous?.screen === 'now-playing' &&
|
|
190
|
+
previous.receiverStyle === 'ultracode' &&
|
|
191
|
+
previous.playbackState === 'playing' &&
|
|
192
|
+
previous.playbackReady);
|
|
193
|
+
}
|
|
167
194
|
export function isEditableInput(input, key) {
|
|
168
195
|
return Boolean(key.backspace || key.delete || input);
|
|
169
196
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { textMuted } from '../theme.js';
|
|
3
4
|
const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
|
|
4
5
|
function LogoSpectrum() {
|
|
5
6
|
return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
|
|
6
7
|
}
|
|
7
8
|
export function Logo() {
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 }));
|
package/dist/ui/page-footer.js
CHANGED
|
@@ -10,7 +10,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
10
10
|
return '↑/↓ move · Enter open · number jump · : command';
|
|
11
11
|
}
|
|
12
12
|
if (screen === 'search' && editingSearch) {
|
|
13
|
-
return 'Type query · ↑/↓ history · Enter search/tune · Esc finish';
|
|
13
|
+
return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
|
|
14
14
|
}
|
|
15
15
|
if (screen === 'search') {
|
|
16
16
|
return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b home';
|
|
@@ -2,16 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
4
4
|
import { truncate } from '../format.js';
|
|
5
|
-
import { textDim, themeAccent } from '../theme.js';
|
|
5
|
+
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
|
|
7
7
|
const accent = themeAccent(theme);
|
|
8
8
|
const lineWidth = Math.max(24, width - 4);
|
|
9
9
|
const promptActive = isAirPlayCodePromptActive(playback);
|
|
10
10
|
const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
|
|
11
11
|
const masked = code.length > 0 ? '*'.repeat(code.length) : '';
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color:
|
|
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:
|
|
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:
|
|
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:
|
|
23
|
-
} })) : (_jsx(Text, { color:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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] ??
|
|
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:
|
|
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 :
|
|
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
|
}
|