@ciphore/radiocli 0.1.6 → 0.1.8

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