@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.
@@ -23,6 +23,7 @@ export const settingsItems = [
23
23
  'Transparent background',
24
24
  'ASCII-safe display',
25
25
  'Reduce motion',
26
+ 'Check for updates',
26
27
  'Refresh provider health',
27
28
  'Learn previous media key',
28
29
  'Learn play/pause media key',
@@ -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
- 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
+ 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
- export function SettingsScreen({ selected, settings, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width }) {
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
- 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
- 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: 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)] })] })] }));
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 settingValue(item, settings, diagnostics, backends, airPlayDevices) {
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: '', gap: '' }
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
- 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
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 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));
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 (_jsxs(React.Fragment, { children: [_jsx(Text, { color: color, children: cell.text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }, cell.key));
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;
@@ -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 === 'l') {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",