@ciphore/radiocli 0.1.8 → 0.2.0
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 +43 -0
- package/README.md +1 -1
- package/dist/cli.js +17 -1
- package/dist/player/player-controller.js +16 -1
- package/dist/providers/provider-manager.js +3 -3
- package/dist/providers/radio-browser.js +3 -1
- package/dist/storage/store.js +18 -5
- package/dist/ui/App.js +278 -14
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/components/Menu.js +2 -1
- package/dist/ui/components/StationList.js +2 -2
- package/dist/ui/components/TopTabs.js +2 -2
- package/dist/ui/help-content.js +1 -0
- package/dist/ui/layout.js +2 -1
- package/dist/ui/page-footer.js +5 -4
- package/dist/ui/playback-footer.js +3 -1
- package/dist/ui/screen-items.js +1 -0
- package/dist/ui/screens/CountriesScreen.js +7 -1
- package/dist/ui/screens/SettingsScreen.js +33 -6
- package/dist/ui/screens/StatsScreen.js +34 -16
- package/dist/ui/screens/screen-render.test.js +54 -2
- package/dist/ui/terminal-mouse.js +15 -0
- package/dist/ui/use-app-input.js +22 -4
- package/dist/ui/use-command-executor.js +6 -1
- package/dist/update-check.js +122 -0
- package/package.json +1 -1
package/dist/ui/screen-items.js
CHANGED
|
@@ -4,7 +4,13 @@ import { Menu, Pointer } from '../components/Menu.js';
|
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
5
|
import { textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
|
+
import { truncate } from '../format.js';
|
|
7
8
|
export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
|
|
8
9
|
const window = visibleWindow(countries, selected, pageSize);
|
|
9
|
-
|
|
10
|
+
const rowWidth = Math.max(24, width - 2);
|
|
11
|
+
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) => {
|
|
12
|
+
const meta = ` · ${country.code} · ${country.stationCount.toLocaleString()} stations`;
|
|
13
|
+
const nameWidth = Math.max(4, rowWidth - 2 - meta.length);
|
|
14
|
+
return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: truncate(country.name, nameWidth) }), _jsx(Text, { color: textMuted, children: truncate(meta, Math.max(0, rowWidth - 2 - nameWidth)) })] }));
|
|
15
|
+
} })] })) : null] }));
|
|
10
16
|
}
|
|
@@ -8,16 +8,35 @@ 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';
|
|
11
|
-
|
|
11
|
+
import { updateStatusText } from '../../update-check.js';
|
|
12
|
+
export function SettingsScreen({ selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
|
|
12
13
|
const accent = themeAccent(theme);
|
|
13
14
|
const lineWidth = Math.max(32, width - 4);
|
|
14
15
|
const health = Object.entries(providerHealth);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const menuWindow = settingsMenuWindow(selected, height);
|
|
17
|
+
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: menuWindow.items, selected: selected - menuWindow.start, keyFor: ({ item }) => item, render: (item, _index, active) => {
|
|
18
|
+
const label = settingLabel(item.item, updateCheck);
|
|
19
|
+
const value = settingValue(item.item, settings, diagnostics, backends, airPlayDevices, updateCheck);
|
|
20
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: label }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: textMuted, children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
|
|
21
|
+
} }) }), _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: ["Version: ", _jsxs(Text, { color: accent, children: ["v", appVersion] }), " \u00B7 Update: ", updateStatusText(updateCheck)] }), _jsxs(Text, { color: textMuted, children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: textMuted, children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
|
|
19
22
|
}
|
|
20
|
-
function
|
|
23
|
+
function settingsMenuWindow(selected, height) {
|
|
24
|
+
if (!height) {
|
|
25
|
+
return { start: 0, items: settingsItems.map(item => ({ item })) };
|
|
26
|
+
}
|
|
27
|
+
const reservedRows = 10;
|
|
28
|
+
const maxRows = Math.max(5, height - reservedRows);
|
|
29
|
+
if (settingsItems.length <= maxRows) {
|
|
30
|
+
return { start: 0, items: settingsItems.map(item => ({ item })) };
|
|
31
|
+
}
|
|
32
|
+
const clampedSelected = Math.min(Math.max(selected, 0), settingsItems.length - 1);
|
|
33
|
+
const start = Math.min(Math.floor(clampedSelected / maxRows) * maxRows, Math.max(0, settingsItems.length - maxRows));
|
|
34
|
+
return {
|
|
35
|
+
start,
|
|
36
|
+
items: settingsItems.slice(start, start + maxRows).map(item => ({ item }))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
|
|
21
40
|
switch (item) {
|
|
22
41
|
case 'Cycle display color':
|
|
23
42
|
return settings.theme;
|
|
@@ -46,9 +65,17 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
|
|
|
46
65
|
return settings.asciiMode ? 'on' : 'off';
|
|
47
66
|
case 'Reduce motion':
|
|
48
67
|
return settings.reduceMotion ? 'on' : 'off';
|
|
68
|
+
case 'Check for updates':
|
|
69
|
+
return updateStatusText(updateCheck);
|
|
49
70
|
case 'Reset learned media keys':
|
|
50
71
|
return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
|
|
51
72
|
default:
|
|
52
73
|
return undefined;
|
|
53
74
|
}
|
|
54
75
|
}
|
|
76
|
+
function settingLabel(item, updateCheck) {
|
|
77
|
+
if (item === 'Check for updates' && updateCheck?.updateAvailable) {
|
|
78
|
+
return 'Install update';
|
|
79
|
+
}
|
|
80
|
+
return item;
|
|
81
|
+
}
|
|
@@ -9,37 +9,54 @@ import { truncate } from '../format.js';
|
|
|
9
9
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
10
10
|
const dayLabelWidth = 5;
|
|
11
11
|
const heatmapCellOptions = [
|
|
12
|
-
{ cell: ' ', gap: ' ' },
|
|
13
12
|
{ cell: ' ', gap: '' },
|
|
14
|
-
{ cell: '
|
|
13
|
+
{ cell: ' ', gap: '' }
|
|
15
14
|
];
|
|
16
|
-
const compactHeatmapCell = '
|
|
15
|
+
const compactHeatmapCell = ' ';
|
|
17
16
|
const compactHeatmapGap = '';
|
|
17
|
+
const tallHeatmapMinHeight = 26;
|
|
18
18
|
export function StatsScreen({ library, theme, width, height }) {
|
|
19
19
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
20
20
|
const stats = computeListeningStats(library.activity.sessions);
|
|
21
21
|
const contentWidth = Math.max(20, width - 4);
|
|
22
|
-
const graph = buildContributionGraph(stats.days, contentWidth);
|
|
22
|
+
const graph = buildContributionGraph(stats.days, contentWidth, height);
|
|
23
23
|
const graphColors = themeContributionColors(theme);
|
|
24
24
|
const favorite = stats.favoriteStation?.name ?? 'none yet';
|
|
25
25
|
const totalHours = stats.totalSeconds / 3600;
|
|
26
26
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
27
27
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
28
28
|
const compact = height < 30;
|
|
29
|
-
|
|
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
|
|
29
|
+
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: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, 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
|
|
31
30
|
? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
|
|
32
31
|
: 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
|
|
33
32
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
function renderContributionCells(cells, graphColors, cellGap) {
|
|
34
|
+
const runs = [];
|
|
35
|
+
for (const cell of cells) {
|
|
36
|
+
const text = cellGap ? `${cell.text}${cell.visible ? cellGap : ''}` : cell.text;
|
|
37
|
+
const previous = runs[runs.length - 1];
|
|
38
|
+
if (previous && previous.visible === cell.visible && previous.level === cell.level) {
|
|
39
|
+
previous.text += text;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
runs.push({
|
|
43
|
+
key: `${runs.length}-${cell.key}`,
|
|
44
|
+
level: cell.level,
|
|
45
|
+
text,
|
|
46
|
+
visible: cell.visible
|
|
47
|
+
});
|
|
48
|
+
}
|
|
41
49
|
}
|
|
42
|
-
return
|
|
50
|
+
return runs.map(run => {
|
|
51
|
+
if (!run.visible) {
|
|
52
|
+
return _jsx(Text, { children: run.text }, run.key);
|
|
53
|
+
}
|
|
54
|
+
const color = graphColors[run.level] ?? graphColors[0] ?? textMuted;
|
|
55
|
+
if (run.text.trim().length === 0) {
|
|
56
|
+
return _jsx(Text, { backgroundColor: color, children: run.text }, run.key);
|
|
57
|
+
}
|
|
58
|
+
return _jsx(Text, { color: color, children: run.text }, run.key);
|
|
59
|
+
});
|
|
43
60
|
}
|
|
44
61
|
function renderLegendCell(color, text, cellGap) {
|
|
45
62
|
if (text.trim().length === 0) {
|
|
@@ -52,7 +69,7 @@ function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, t
|
|
|
52
69
|
const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
|
|
53
70
|
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 })] }) })] }));
|
|
54
71
|
}
|
|
55
|
-
export function buildContributionGraph(days, width) {
|
|
72
|
+
export function buildContributionGraph(days, width, height = tallHeatmapMinHeight) {
|
|
56
73
|
const year = graphYear(days);
|
|
57
74
|
const weeks = calendarYearWeeks(year);
|
|
58
75
|
const largestCell = heatmapCellOptions.find(option => dayLabelWidth + weeks.length * (option.cell.length + option.gap.length) <= width);
|
|
@@ -62,6 +79,7 @@ export function buildContributionGraph(days, width) {
|
|
|
62
79
|
const cellWidth = largeCellWidth > 0 ? largeCellWidth : compactCellWidth;
|
|
63
80
|
const cellText = selectedCell.cell;
|
|
64
81
|
const cellGap = selectedCell.gap;
|
|
82
|
+
const tileHeight = cellText.trim().length === 0 && height >= tallHeatmapMinHeight ? 2 : 1;
|
|
65
83
|
const secondsByDate = new Map(days.map(day => [day.date, day.seconds]));
|
|
66
84
|
const yearDays = days.filter(day => parseLocalDay(day.date).getFullYear() === year);
|
|
67
85
|
const scaleSeconds = contributionScaleSeconds(yearDays);
|
|
@@ -84,7 +102,7 @@ export function buildContributionGraph(days, width) {
|
|
|
84
102
|
return { key: date, level, text: cellText, visible: true };
|
|
85
103
|
})
|
|
86
104
|
}));
|
|
87
|
-
return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, rows };
|
|
105
|
+
return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, tileHeight, rows };
|
|
88
106
|
}
|
|
89
107
|
export function contributionScaleSeconds(days) {
|
|
90
108
|
const activeSeconds = days
|
|
@@ -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 { CountriesScreen } from './CountriesScreen.js';
|
|
9
10
|
import { buildContributionGraph, contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
|
|
10
11
|
import { settingsItems } from '../screen-items.js';
|
|
11
12
|
import { defaultExploreCursor } from '../app-state.js';
|
|
@@ -60,13 +61,34 @@ const settings = {
|
|
|
60
61
|
describe('SettingsScreen rendering', () => {
|
|
61
62
|
it('renders the new display and playback toggles with their values', () => {
|
|
62
63
|
const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
|
|
63
|
-
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
|
|
64
|
+
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
|
|
64
65
|
const frame = lastFrame() ?? '';
|
|
65
66
|
expect(frame).toContain('Resume last station on launch');
|
|
66
67
|
expect(frame).toContain('ASCII-safe display');
|
|
67
68
|
expect(frame).toContain('Reduce motion');
|
|
68
69
|
expect(frame).toContain('Transparent background');
|
|
69
70
|
});
|
|
71
|
+
it('changes the update settings row when an update is available', () => {
|
|
72
|
+
const settingsIndex = Math.max(0, settingsItems.indexOf('Check for updates'));
|
|
73
|
+
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.10', updateAvailable: true }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
|
|
74
|
+
const frame = lastFrame() ?? '';
|
|
75
|
+
expect(frame).toContain('Install update');
|
|
76
|
+
expect(frame).toContain('v0.1.10 available');
|
|
77
|
+
});
|
|
78
|
+
it('keeps the selected update row visible in a constrained Settings pane', () => {
|
|
79
|
+
const settingsIndex = Math.max(0, settingsItems.indexOf('Check for updates'));
|
|
80
|
+
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80, height: 18 }));
|
|
81
|
+
const frame = lastFrame() ?? '';
|
|
82
|
+
expect(frame).toContain('> Check for updates');
|
|
83
|
+
});
|
|
84
|
+
it('keeps Reduce motion visible between ASCII-safe display and update checks', () => {
|
|
85
|
+
const settingsIndex = Math.max(0, settingsItems.indexOf('Reduce motion'));
|
|
86
|
+
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, appVersion: "0.1.9", updateCheck: { checkedAt: '2026-07-07T00:00:00.000Z', currentVersion: '0.1.9', latestVersion: '0.1.9', updateAvailable: false }, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80, height: 18 }));
|
|
87
|
+
const frame = lastFrame() ?? '';
|
|
88
|
+
expect(frame).toContain(' ASCII-safe display');
|
|
89
|
+
expect(frame).toContain('> Reduce motion');
|
|
90
|
+
expect(frame).toContain(' Check for updates');
|
|
91
|
+
});
|
|
70
92
|
});
|
|
71
93
|
function renderExplore(asciiMode) {
|
|
72
94
|
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
@@ -82,6 +104,20 @@ describe('Explore world map rendering', () => {
|
|
|
82
104
|
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
83
105
|
});
|
|
84
106
|
});
|
|
107
|
+
describe('CountriesScreen rendering', () => {
|
|
108
|
+
it('keeps long country rows to one terminal line', () => {
|
|
109
|
+
const frame = render(_jsx(CountriesScreen, { countries: [
|
|
110
|
+
{
|
|
111
|
+
name: 'The Extremely Long Democratic Republic Of The Country With A Very Long Name',
|
|
112
|
+
code: 'TL',
|
|
113
|
+
stationCount: 123456789
|
|
114
|
+
}
|
|
115
|
+
], selected: 0, loading: false, filter: "", editingFilter: false, theme: "green", pageSize: 1, width: 48 })).lastFrame() ?? '';
|
|
116
|
+
expect(frame).toContain('>');
|
|
117
|
+
expect(frame).toContain('…');
|
|
118
|
+
expect(frame).not.toContain('Very Long Name');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
85
121
|
describe('StatsScreen rendering', () => {
|
|
86
122
|
it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
|
|
87
123
|
const library = {
|
|
@@ -115,7 +151,8 @@ describe('StatsScreen rendering', () => {
|
|
|
115
151
|
expect(graph.months.startsWith('Jan')).toBe(true);
|
|
116
152
|
expect(graph.months).toContain('Dec');
|
|
117
153
|
expect(graph.cellText).toBe(' ');
|
|
118
|
-
expect(graph.cellGap).toBe('
|
|
154
|
+
expect(graph.cellGap).toBe('');
|
|
155
|
+
expect(graph.tileHeight).toBe(2);
|
|
119
156
|
expect(graph.rows[4]?.cells[0]).toMatchObject({
|
|
120
157
|
level: 4,
|
|
121
158
|
text: ' ',
|
|
@@ -137,6 +174,21 @@ describe('StatsScreen rendering', () => {
|
|
|
137
174
|
], 128);
|
|
138
175
|
expect(normalWidthGraph.cellText).toBe(' ');
|
|
139
176
|
expect(normalWidthGraph.cellGap).toBe('');
|
|
177
|
+
const compactWidthGraph = buildContributionGraph([
|
|
178
|
+
{ date: '2026-01-01', seconds: 3600 },
|
|
179
|
+
{ date: '2026-12-31', seconds: 0 }
|
|
180
|
+
], 80);
|
|
181
|
+
expect(compactWidthGraph.cellText).toBe(' ');
|
|
182
|
+
expect(compactWidthGraph.cellGap).toBe('');
|
|
183
|
+
});
|
|
184
|
+
it('uses one-line heatmap cells when the stats panel is height constrained', () => {
|
|
185
|
+
const graph = buildContributionGraph([
|
|
186
|
+
{ date: '2026-01-01', seconds: 3600 },
|
|
187
|
+
{ date: '2026-12-31', seconds: 0 }
|
|
188
|
+
], 170, 20);
|
|
189
|
+
expect(graph.cellText).toBe(' ');
|
|
190
|
+
expect(graph.cellGap).toBe('');
|
|
191
|
+
expect(graph.tileHeight).toBe(1);
|
|
140
192
|
});
|
|
141
193
|
it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
|
|
142
194
|
const days = [
|
|
@@ -14,6 +14,21 @@ export function parseSgrMouseEvents(input) {
|
|
|
14
14
|
export function primaryMousePress(events) {
|
|
15
15
|
return events.find(event => event.pressed && (event.button & 3) === 0 && (event.button & 96) === 0) ?? null;
|
|
16
16
|
}
|
|
17
|
+
export function wheelScrollDelta(events) {
|
|
18
|
+
return events.reduce((delta, event) => {
|
|
19
|
+
if (!event.pressed) {
|
|
20
|
+
return delta;
|
|
21
|
+
}
|
|
22
|
+
const button = event.button & 127;
|
|
23
|
+
if (button === 64) {
|
|
24
|
+
return delta - 1;
|
|
25
|
+
}
|
|
26
|
+
if (button === 65) {
|
|
27
|
+
return delta + 1;
|
|
28
|
+
}
|
|
29
|
+
return delta;
|
|
30
|
+
}, 0);
|
|
31
|
+
}
|
|
17
32
|
export function exploreCursorForMouseCell(x, y, frameWidth, layout) {
|
|
18
33
|
if (layout.compact) {
|
|
19
34
|
return null;
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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, searchEditingArrowAction, shouldHandleKeyboardEvent } from './app-state.js';
|
|
5
|
-
import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
|
|
4
|
+
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldToggleNearbyLocationShortcut } from './app-state.js';
|
|
5
|
+
import { parseSgrMouseEvents, primaryMousePress, wheelScrollDelta } from './terminal-mouse.js';
|
|
6
6
|
import { completeCommand } from './help-content.js';
|
|
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 }) {
|
|
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, updateFromSettings }) {
|
|
8
8
|
useEffect(() => {
|
|
9
9
|
const onData = (data) => {
|
|
10
10
|
const rawInput = String(data);
|
|
@@ -22,8 +22,13 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
22
22
|
}
|
|
23
23
|
const mouseEvents = parseSgrMouseEvents(rawInput);
|
|
24
24
|
if (mouseEvents.length > 0) {
|
|
25
|
+
const wheelDelta = wheelScrollDelta(mouseEvents);
|
|
25
26
|
const click = primaryMousePress(mouseEvents);
|
|
26
27
|
lastRawTransportAtRef.current = Date.now();
|
|
28
|
+
if (wheelDelta !== 0 && shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter)) {
|
|
29
|
+
setSelected(value => clamp(value + wheelDelta * 3, currentItemCount(screen) - 1));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
27
32
|
if (!commandMode && screen === 'explore' && click) {
|
|
28
33
|
moveExploreCursorToCell(click.x, click.y);
|
|
29
34
|
}
|
|
@@ -256,7 +261,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
256
261
|
toggleRadioGarden();
|
|
257
262
|
return;
|
|
258
263
|
}
|
|
259
|
-
if (input
|
|
264
|
+
if (shouldToggleNearbyLocationShortcut(input, screen)) {
|
|
260
265
|
toggleNearbyLocation();
|
|
261
266
|
return;
|
|
262
267
|
}
|
|
@@ -303,6 +308,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
303
308
|
return;
|
|
304
309
|
}
|
|
305
310
|
if (input === 'b' || key.escape) {
|
|
311
|
+
if (screen === 'stations') {
|
|
312
|
+
go('countries');
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
306
315
|
go('home');
|
|
307
316
|
return;
|
|
308
317
|
}
|
|
@@ -422,6 +431,9 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
422
431
|
else if (item === 'Reduce motion') {
|
|
423
432
|
toggleSetting('reduceMotion');
|
|
424
433
|
}
|
|
434
|
+
else if (item === 'Check for updates') {
|
|
435
|
+
void updateFromSettings();
|
|
436
|
+
}
|
|
425
437
|
else if (item === 'Refresh provider health') {
|
|
426
438
|
refreshProviderHealth();
|
|
427
439
|
setMessage('Provider health refreshed.');
|
|
@@ -457,6 +469,12 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
457
469
|
}
|
|
458
470
|
});
|
|
459
471
|
}
|
|
472
|
+
function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
|
|
473
|
+
if (commandMode || editingCountryFilter) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
return ['countries', 'map', 'stations', 'search', 'nearby', 'explore', 'library'].includes(screen);
|
|
477
|
+
}
|
|
460
478
|
function exploreMoveForInput(input) {
|
|
461
479
|
const normalized = input.toLowerCase();
|
|
462
480
|
if (normalized === 'w') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
|
|
3
|
-
export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
|
|
3
|
+
export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
|
|
4
4
|
return useCallback(async (rawCommand) => {
|
|
5
5
|
const trimmed = rawCommand.trim();
|
|
6
6
|
if (!trimmed) {
|
|
@@ -136,6 +136,10 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
136
136
|
go('settings');
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
|
+
if (name === 'update') {
|
|
140
|
+
await updateCommand();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
139
143
|
if (name === 'stop') {
|
|
140
144
|
setLibrary(store.finishActiveListeningSession());
|
|
141
145
|
await player.stop();
|
|
@@ -189,6 +193,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
189
193
|
store,
|
|
190
194
|
toggleFavorite,
|
|
191
195
|
toggleMute,
|
|
196
|
+
updateCommand,
|
|
192
197
|
updateSettings
|
|
193
198
|
]);
|
|
194
199
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { appVersion } from './version.js';
|
|
4
|
+
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const UPDATE_PACKAGE_NAME = '@ciphore/radiocli';
|
|
6
|
+
export async function checkForUpdate({ currentVersion = appVersion(), fetchImpl = fetch, now = new Date(), packageName = UPDATE_PACKAGE_NAME, timeoutMs = 3000 } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetchWithTimeout(registryLatestUrl(packageName), fetchImpl, timeoutMs);
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`npm registry returned ${response.status}`);
|
|
11
|
+
}
|
|
12
|
+
const parsed = await response.json();
|
|
13
|
+
const latestVersion = typeof parsed.version === 'string' ? parsed.version : undefined;
|
|
14
|
+
if (!latestVersion) {
|
|
15
|
+
throw new Error('npm registry response did not include a version');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
checkedAt: now.toISOString(),
|
|
19
|
+
currentVersion,
|
|
20
|
+
latestVersion,
|
|
21
|
+
updateAvailable: compareSemver(latestVersion, currentVersion) > 0
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return {
|
|
26
|
+
checkedAt: now.toISOString(),
|
|
27
|
+
currentVersion,
|
|
28
|
+
updateAvailable: false,
|
|
29
|
+
error: error instanceof Error ? error.message : 'Update check failed'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function shouldCheckForUpdate(updateCheck, now = Date.now()) {
|
|
34
|
+
if (process.env.RADIOCLI_DISABLE_UPDATE_CHECK === '1' || process.env.CI === 'true') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (!updateCheck?.checkedAt) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const checkedAt = Date.parse(updateCheck.checkedAt);
|
|
41
|
+
return !Number.isFinite(checkedAt) || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
|
|
42
|
+
}
|
|
43
|
+
export function updateStatusText(updateCheck) {
|
|
44
|
+
if (!updateCheck) {
|
|
45
|
+
return 'not checked yet';
|
|
46
|
+
}
|
|
47
|
+
if (updateCheck.updateAvailable && updateCheck.latestVersion) {
|
|
48
|
+
return `v${updateCheck.latestVersion} available`;
|
|
49
|
+
}
|
|
50
|
+
if (updateCheck.error) {
|
|
51
|
+
return `check failed: ${updateCheck.error}`;
|
|
52
|
+
}
|
|
53
|
+
return updateCheck.latestVersion ? `current at v${updateCheck.latestVersion}` : 'not checked yet';
|
|
54
|
+
}
|
|
55
|
+
export function updateCommandForInstall(entryPath = process.argv[1]) {
|
|
56
|
+
const resolved = resolvePath(entryPath);
|
|
57
|
+
const haystack = [entryPath, resolved].filter(Boolean).join('\n');
|
|
58
|
+
if (/\/(?:opt\/homebrew|usr\/local)\/(?:Cellar|Homebrew)\//.test(haystack) || /\/\.linuxbrew\/(?:Cellar|Homebrew)\//.test(haystack)) {
|
|
59
|
+
return { method: 'homebrew', command: 'brew update && brew upgrade radiocli' };
|
|
60
|
+
}
|
|
61
|
+
if (/\/node_modules\/@ciphore\/radiocli\//.test(haystack)) {
|
|
62
|
+
return { method: 'npm', command: 'npm install -g @ciphore/radiocli@latest' };
|
|
63
|
+
}
|
|
64
|
+
return { method: 'unknown', command: 'npm install -g @ciphore/radiocli@latest' };
|
|
65
|
+
}
|
|
66
|
+
export function installUpdate(command = updateCommandForInstall().command) {
|
|
67
|
+
return new Promise(resolve => {
|
|
68
|
+
const shell = process.platform === 'win32' ? 'cmd.exe' : 'sh';
|
|
69
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
|
70
|
+
const child = spawn(shell, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
71
|
+
const chunks = [];
|
|
72
|
+
child.stdout.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
73
|
+
child.stderr.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
74
|
+
child.on('error', error => {
|
|
75
|
+
resolve({ ok: false, command, output: error.message });
|
|
76
|
+
});
|
|
77
|
+
child.on('close', code => {
|
|
78
|
+
const output = Buffer.concat(chunks).toString('utf8').trim();
|
|
79
|
+
resolve({ ok: code === 0, command, output });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function compareSemver(left, right) {
|
|
84
|
+
const leftParts = semverParts(left);
|
|
85
|
+
const rightParts = semverParts(right);
|
|
86
|
+
for (let index = 0; index < 3; index += 1) {
|
|
87
|
+
const delta = leftParts[index] - rightParts[index];
|
|
88
|
+
if (delta !== 0) {
|
|
89
|
+
return delta;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
function registryLatestUrl(packageName) {
|
|
95
|
+
return `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
|
|
96
|
+
}
|
|
97
|
+
async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
try {
|
|
101
|
+
return await fetchImpl(url, { signal: controller.signal });
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function semverParts(version) {
|
|
108
|
+
const normalized = version.trim().replace(/^v/i, '').split(/[+-]/)[0] ?? '';
|
|
109
|
+
const [major = '0', minor = '0', patch = '0'] = normalized.split('.');
|
|
110
|
+
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
111
|
+
}
|
|
112
|
+
function resolvePath(path) {
|
|
113
|
+
if (!path) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return realpathSync(path);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return path;
|
|
121
|
+
}
|
|
122
|
+
}
|