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