@ciphore/radiocli 0.1.6 → 0.1.7
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 +12 -0
- package/README.md +1 -1
- package/dist/ui/app-state.js +9 -0
- package/dist/ui/page-footer.js +1 -1
- package/dist/ui/screens/StatsScreen.js +22 -10
- package/dist/ui/screens/screen-render.test.js +38 -0
- package/dist/ui/use-app-input.js +15 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.7] - 2026-06-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Search results can now be navigated with `Up` / `Down` while editing the
|
|
15
|
+
search query; search-history recall remains available with
|
|
16
|
+
`Ctrl+Up` / `Ctrl+Down`.
|
|
17
|
+
- The listening stats activity heatmap now renders day cells as square markers
|
|
18
|
+
and uses a capped contribution scale so one unusually long listening day does
|
|
19
|
+
not flatten the rest of the Less/More color range.
|
|
20
|
+
|
|
10
21
|
## [0.1.6] - 2026-06-17
|
|
11
22
|
|
|
12
23
|
### Added
|
|
@@ -146,6 +157,7 @@ Initial public release.
|
|
|
146
157
|
|
|
147
158
|
[0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
|
|
148
159
|
[0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
|
|
160
|
+
[0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
|
|
149
161
|
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
150
162
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
151
163
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -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/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
|
}
|
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';
|
|
@@ -6,6 +6,7 @@ import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
|
6
6
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
7
7
|
import { truncate } from '../format.js';
|
|
8
8
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
9
|
+
const heatmapSquare = '■';
|
|
9
10
|
export function StatsScreen({ library, theme, width, height }) {
|
|
10
11
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
11
12
|
const stats = computeListeningStats(library.activity.sessions);
|
|
@@ -17,7 +18,7 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
17
18
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
18
19
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
19
20
|
const compact = height < 30;
|
|
20
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: [
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: [heatmapSquare, _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
|
|
21
22
|
? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
|
|
22
23
|
: 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
|
|
23
24
|
}
|
|
@@ -27,34 +28,45 @@ function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, t
|
|
|
27
28
|
return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [leftLabel, ": "] }), _jsx(Text, { color: accent, children: leftValue })] }) }), _jsx(Text, { children: ' '.repeat(leftPadding > 2 ? 2 : leftPadding) }), _jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [rightLabel, ": "] }), _jsx(Text, { color: accent, children: rightValue })] }) })] }));
|
|
28
29
|
}
|
|
29
30
|
function buildContributionGraph(days, width) {
|
|
30
|
-
const cellWidth = width >=
|
|
31
|
+
const cellWidth = width >= 112 ? 2 : 1;
|
|
31
32
|
const weeks = Array.from({ length: 53 }, (_, weekIndex) => days.slice(weekIndex * 7, weekIndex * 7 + 7)).filter(week => week.length > 0);
|
|
32
|
-
const
|
|
33
|
+
const scaleSeconds = contributionScaleSeconds(days);
|
|
33
34
|
const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
|
|
34
35
|
const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
|
|
35
36
|
key: `day-${dayIndex}`,
|
|
36
37
|
label: labels[dayIndex] ?? '',
|
|
37
38
|
cells: weeks.map(week => {
|
|
38
39
|
const day = week[dayIndex];
|
|
39
|
-
const level = contributionLevel(day?.seconds ?? 0,
|
|
40
|
-
const text = cellWidth > 1 ? `${
|
|
40
|
+
const level = contributionLevel(day?.seconds ?? 0, scaleSeconds);
|
|
41
|
+
const text = cellWidth > 1 ? `${heatmapSquare} ` : heatmapSquare;
|
|
41
42
|
return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text };
|
|
42
43
|
})
|
|
43
44
|
}));
|
|
44
45
|
return { months: monthLine(weeks, cellWidth), rows };
|
|
45
46
|
}
|
|
46
|
-
function
|
|
47
|
+
export function contributionScaleSeconds(days) {
|
|
48
|
+
const activeSeconds = days
|
|
49
|
+
.map(day => day.seconds)
|
|
50
|
+
.filter(seconds => seconds > 0)
|
|
51
|
+
.sort((left, right) => left - right);
|
|
52
|
+
if (activeSeconds.length === 0) {
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const percentileIndex = Math.min(activeSeconds.length - 1, Math.ceil(activeSeconds.length * 0.95) - 1);
|
|
56
|
+
return Math.max(1, activeSeconds[percentileIndex] ?? activeSeconds[activeSeconds.length - 1] ?? 1);
|
|
57
|
+
}
|
|
58
|
+
export function contributionLevel(seconds, scaleSeconds) {
|
|
47
59
|
if (seconds <= 0) {
|
|
48
60
|
return 0;
|
|
49
61
|
}
|
|
50
|
-
const ratio = seconds /
|
|
51
|
-
if (ratio
|
|
62
|
+
const ratio = seconds / Math.max(1, scaleSeconds);
|
|
63
|
+
if (ratio >= 0.75) {
|
|
52
64
|
return 4;
|
|
53
65
|
}
|
|
54
|
-
if (ratio
|
|
66
|
+
if (ratio >= 0.5) {
|
|
55
67
|
return 3;
|
|
56
68
|
}
|
|
57
|
-
if (ratio
|
|
69
|
+
if (ratio >= 0.25) {
|
|
58
70
|
return 2;
|
|
59
71
|
}
|
|
60
72
|
return 1;
|
|
@@ -6,6 +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
10
|
import { settingsItems } from '../screen-items.js';
|
|
10
11
|
import { defaultExploreCursor } from '../app-state.js';
|
|
11
12
|
const station = {
|
|
@@ -81,6 +82,43 @@ describe('Explore world map rendering', () => {
|
|
|
81
82
|
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
82
83
|
});
|
|
83
84
|
});
|
|
85
|
+
describe('StatsScreen rendering', () => {
|
|
86
|
+
it('renders activity heatmap days as square markers instead of stretched blocks', () => {
|
|
87
|
+
const library = {
|
|
88
|
+
recent: [],
|
|
89
|
+
favorites: [],
|
|
90
|
+
imported: [],
|
|
91
|
+
trackHistory: [],
|
|
92
|
+
searchHistory: [],
|
|
93
|
+
activity: {
|
|
94
|
+
sessions: [
|
|
95
|
+
{
|
|
96
|
+
id: 'listen',
|
|
97
|
+
station,
|
|
98
|
+
startedAt: new Date(2026, 4, 24, 12).toISOString(),
|
|
99
|
+
endedAt: new Date(2026, 4, 24, 13).toISOString(),
|
|
100
|
+
listenedSeconds: 3600
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
settings
|
|
105
|
+
};
|
|
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('■');
|
|
108
|
+
expect(frame).not.toContain('██');
|
|
109
|
+
});
|
|
110
|
+
it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
|
|
111
|
+
const days = [
|
|
112
|
+
...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
|
|
113
|
+
{ date: '2026-05-20', seconds: 36_000 }
|
|
114
|
+
];
|
|
115
|
+
const scaleSeconds = contributionScaleSeconds(days);
|
|
116
|
+
expect(scaleSeconds).toBe(3600);
|
|
117
|
+
expect(contributionLevel(3600, scaleSeconds)).toBe(4);
|
|
118
|
+
expect(contributionLevel(900, scaleSeconds)).toBe(2);
|
|
119
|
+
expect(contributionLevel(0, scaleSeconds)).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
84
122
|
describe('HelpScreen rendering', () => {
|
|
85
123
|
it('lists keybinding sections and : commands', () => {
|
|
86
124
|
const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { useInput } from 'ink';
|
|
3
3
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
4
|
-
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
|
|
4
|
+
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent } from './app-state.js';
|
|
5
5
|
import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
|
|
6
6
|
import { completeCommand } from './help-content.js';
|
|
7
7
|
export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
|
|
@@ -138,12 +138,20 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
138
138
|
setEditingSearch(false);
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
const arrowAction = searchEditingArrowAction(key, currentItemCount('search') > 0);
|
|
142
|
+
if (arrowAction) {
|
|
143
|
+
if (arrowAction === 'history-older') {
|
|
144
|
+
recallSearchHistory('older');
|
|
145
|
+
}
|
|
146
|
+
else if (arrowAction === 'history-newer') {
|
|
147
|
+
recallSearchHistory('newer');
|
|
148
|
+
}
|
|
149
|
+
else if (arrowAction === 'select-previous') {
|
|
150
|
+
setSelected(value => clamp(value - 1, currentItemCount(screen) - 1));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
setSelected(value => clamp(value + 1, currentItemCount(screen) - 1));
|
|
154
|
+
}
|
|
147
155
|
return;
|
|
148
156
|
}
|
|
149
157
|
if (isEditableInput(input, key)) {
|