@ciphore/radiocli 0.1.7 → 0.1.9

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,9 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { Menu, Pointer } from '../components/Menu.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { themeAccent } from '../theme.js';
5
+ import { textMuted, themeAccent } from '../theme.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
+ 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: "gray", children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", 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: "gray", 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
  }
@@ -5,7 +5,7 @@ import { StationList } from '../components/StationList.js';
5
5
  import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
6
  import { computeExploreMapLayout } from '../explore-map-layout.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
- import { exploreMapLand, mapMarker, panelBorder, themeAccent } from '../theme.js';
8
+ import { exploreMapLand, mapMarker, panelBorder, textMuted, themeAccent } from '../theme.js';
9
9
  import { panelBorderStyle, useDisplay } from '../display-context.js';
10
10
  import { toAsciiSafe } from '../ascii.js';
11
11
  export function ExploreScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
@@ -13,7 +13,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, th
13
13
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
14
14
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
15
15
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
16
- return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: "gray", children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
16
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
17
17
  }
18
18
  function CosmoMapLine({ row, theme }) {
19
19
  const { ascii } = useDisplay();
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { themeAccent } from '../theme.js';
3
+ import { textMuted, themeAccent } from '../theme.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { commandHelp, keyHelpSections } from '../help-content.js';
6
6
  import { truncate } from '../format.js';
@@ -8,5 +8,5 @@ export function HelpScreen({ theme, width }) {
8
8
  const accent = themeAccent(theme);
9
9
  const keyColumnWidth = 16;
10
10
  const lineWidth = Math.max(28, width - 2);
11
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: "gray", children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: "gray", children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
11
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: textMuted, children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: textMuted, children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
12
12
  }
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { Logo } from '../components/Logo.js';
4
4
  import { Menu, Pointer } from '../components/Menu.js';
5
- import { themeAccent } from '../theme.js';
5
+ import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
7
  import { playbackBackendLabel } from '../../player/backend-install.js';
8
8
  export function HomeScreen({ selected, theme, library, playback }) {
9
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
10
10
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { mapLand, mapWater, themeAccent, themeContributionColors } from '../theme.js';
3
+ import { mapLand, mapWater, textMuted, themeAccent, themeContributionColors } from '../theme.js';
4
4
  import { useDisplay } from '../display-context.js';
5
5
  import { toAsciiSafe } from '../ascii.js';
6
6
  import { visibleWindow } from '../list-window.js';
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
18
18
  const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
19
19
  const topWidth = mode === 'full' ? 50 : contentWidth;
20
20
  const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: "gray", children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: "gray", children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: "gray", children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
22
22
  }
23
23
  function MapLine({ row, theme }) {
24
24
  const { ascii } = useDisplay();
@@ -55,7 +55,7 @@ function mapColor(kind, theme) {
55
55
  return colors[2] ?? themeAccent(theme);
56
56
  }
57
57
  if (kind === 'level1') {
58
- return colors[1] ?? 'gray';
58
+ return colors[1] ?? textMuted;
59
59
  }
60
60
  if (kind === 'land') {
61
61
  return mapLand;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
4
- import { themeAccent } from '../theme.js';
4
+ import { textMuted, themeAccent } from '../theme.js';
5
5
  import { panelBorderStyle, useDisplay } from '../display-context.js';
6
6
  import { toAsciiSafe } from '../ascii.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
@@ -36,9 +36,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
36
36
  const dialLabel = receiverDialLabel(station);
37
37
  const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
38
38
  const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
39
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: "gray", children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
39
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: textMuted, children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
40
40
  ? renderSegments(row.segments)
41
- : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children: a(favoriteText) })] }), _jsx(Text, { color: "gray", children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Diagnostics" }), _jsx(Text, { color: "gray", children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: "gray", children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: "gray", children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: "gray", children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
41
+ : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), _jsx(Text, { color: textMuted, children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
42
42
  }
43
43
  function asciifyVisualRow(row) {
44
44
  return {
@@ -95,8 +95,8 @@ function stationFrequency(name) {
95
95
  function renderSegments(segments) {
96
96
  let offset = 0;
97
97
  return segments.map(segment => {
98
- const key = `${offset}-${segment.color}`;
98
+ const key = `${offset}-${segment.color}-${segment.backgroundColor ?? ''}-${segment.bold ? 'bold' : ''}`;
99
99
  offset += segment.text.length;
100
- return (_jsx(Text, { color: segment.color, children: segment.text }, key));
100
+ return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: segment.text }, key));
101
101
  });
102
102
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { StationList } from '../components/StationList.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { panelBorder, themeAccent } from '../theme.js';
5
+ import { panelBorder, textMuted, themeAccent } from '../theme.js';
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { truncate } from '../format.js';
8
8
  export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
@@ -10,5 +10,5 @@ export function SearchScreen({ query, editing, loading, stations, selected, them
10
10
  const contentWidth = Math.max(40, width);
11
11
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
12
12
  const inputTextWidth = Math.max(8, inputWidth - 8);
13
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : '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,48 +1,108 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
2
3
  import { Box, Text } from 'ink';
3
4
  import { computeListeningStats } from '../../activity/stats.js';
4
- import { panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
5
+ import { panelBorder, textHighlight, textMuted, themeAccent, themeContributionColors } from '../theme.js';
5
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
6
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
7
8
  import { truncate } from '../format.js';
8
9
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
9
- const heatmapSquare = '■';
10
+ const dayLabelWidth = 5;
11
+ const heatmapCellOptions = [
12
+ { cell: ' ', gap: '' },
13
+ { cell: ' ', gap: '' }
14
+ ];
15
+ const compactHeatmapCell = ' ';
16
+ const compactHeatmapGap = '';
17
+ const tallHeatmapMinHeight = 26;
10
18
  export function StatsScreen({ library, theme, width, height }) {
11
19
  const { panel: panelBackground, ascii } = useDisplay();
12
20
  const stats = computeListeningStats(library.activity.sessions);
13
21
  const contentWidth = Math.max(20, width - 4);
14
- const graph = buildContributionGraph(stats.days, contentWidth);
22
+ const graph = buildContributionGraph(stats.days, contentWidth, height);
15
23
  const graphColors = themeContributionColors(theme);
16
24
  const favorite = stats.favoriteStation?.name ?? 'none yet';
17
25
  const totalHours = stats.totalSeconds / 3600;
18
26
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
19
27
  const favoriteWidth = Math.max(8, metricWidth - 18);
20
28
  const compact = height < 30;
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: [heatmapSquare, _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
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
22
30
  ? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
23
31
  : 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
24
32
  }
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
+ }
49
+ }
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
+ });
60
+ }
61
+ function renderLegendCell(color, text, cellGap) {
62
+ if (text.trim().length === 0) {
63
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: color, children: text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }));
64
+ }
65
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: color, children: text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }));
66
+ }
25
67
  function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, theme) {
26
68
  const accent = themeAccent(theme);
27
69
  const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
28
- return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "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 })] }) })] }));
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 })] }) })] }));
29
71
  }
30
- function buildContributionGraph(days, width) {
31
- const cellWidth = width >= 112 ? 2 : 1;
32
- const weeks = Array.from({ length: 53 }, (_, weekIndex) => days.slice(weekIndex * 7, weekIndex * 7 + 7)).filter(week => week.length > 0);
33
- const scaleSeconds = contributionScaleSeconds(days);
72
+ export function buildContributionGraph(days, width, height = tallHeatmapMinHeight) {
73
+ const year = graphYear(days);
74
+ const weeks = calendarYearWeeks(year);
75
+ const largestCell = heatmapCellOptions.find(option => dayLabelWidth + weeks.length * (option.cell.length + option.gap.length) <= width);
76
+ const selectedCell = largestCell ?? { cell: compactHeatmapCell, gap: compactHeatmapGap };
77
+ const largeCellWidth = selectedCell.cell.length + selectedCell.gap.length;
78
+ const compactCellWidth = compactHeatmapCell.length + compactHeatmapGap.length;
79
+ const cellWidth = largeCellWidth > 0 ? largeCellWidth : compactCellWidth;
80
+ const cellText = selectedCell.cell;
81
+ const cellGap = selectedCell.gap;
82
+ const tileHeight = cellText.trim().length === 0 && height >= tallHeatmapMinHeight ? 2 : 1;
83
+ const secondsByDate = new Map(days.map(day => [day.date, day.seconds]));
84
+ const yearDays = days.filter(day => parseLocalDay(day.date).getFullYear() === year);
85
+ const scaleSeconds = contributionScaleSeconds(yearDays);
34
86
  const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
35
87
  const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
36
88
  key: `day-${dayIndex}`,
37
89
  label: labels[dayIndex] ?? '',
38
90
  cells: weeks.map(week => {
39
91
  const day = week[dayIndex];
40
- const level = contributionLevel(day?.seconds ?? 0, scaleSeconds);
41
- const text = cellWidth > 1 ? `${heatmapSquare} ` : heatmapSquare;
42
- return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text };
92
+ if (!day) {
93
+ return {
94
+ key: `empty-${dayIndex}`,
95
+ level: 0,
96
+ text: ' '.repeat(cellWidth),
97
+ visible: false
98
+ };
99
+ }
100
+ const date = localDay(day.date);
101
+ const level = day.visible ? contributionLevel(secondsByDate.get(date) ?? 0, scaleSeconds) : 0;
102
+ return { key: date, level, text: cellText, visible: true };
43
103
  })
44
104
  }));
45
- return { months: monthLine(weeks, cellWidth), rows };
105
+ return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, tileHeight, rows };
46
106
  }
47
107
  export function contributionScaleSeconds(days) {
48
108
  const activeSeconds = days
@@ -71,29 +131,55 @@ export function contributionLevel(seconds, scaleSeconds) {
71
131
  }
72
132
  return 1;
73
133
  }
74
- function monthLine(weeks, cellWidth) {
75
- const cells = weeks.map((week, index) => {
76
- const first = week[0];
77
- if (!first) {
78
- return ''.padEnd(cellWidth, ' ');
134
+ function graphYear(days) {
135
+ const lastDay = days[days.length - 1];
136
+ return lastDay ? parseLocalDay(lastDay.date).getFullYear() : new Date().getFullYear();
137
+ }
138
+ function calendarYearWeeks(year) {
139
+ const yearStart = new Date(year, 0, 1);
140
+ const yearEnd = new Date(year, 11, 31);
141
+ const gridStart = addLocalDays(yearStart, -yearStart.getDay());
142
+ const gridEnd = addLocalDays(yearEnd, 6 - yearEnd.getDay());
143
+ const weeks = [];
144
+ for (let cursor = gridStart; cursor.getTime() <= gridEnd.getTime(); cursor = addLocalDays(cursor, 7)) {
145
+ weeks.push(Array.from({ length: 7 }, (_, dayIndex) => {
146
+ const date = addLocalDays(cursor, dayIndex);
147
+ return { date, visible: date.getFullYear() === year };
148
+ }));
149
+ }
150
+ return weeks;
151
+ }
152
+ function monthLine(weeks, cellWidth, year) {
153
+ const cells = Array.from({ length: weeks.length * cellWidth }, () => ' ');
154
+ for (let month = 0; month < 12; month += 1) {
155
+ const label = monthLabels[month];
156
+ const firstOfMonth = localDay(new Date(year, month, 1));
157
+ const weekIndex = weeks.findIndex(week => week.some(day => localDay(day.date) === firstOfMonth));
158
+ if (weekIndex < 0) {
159
+ continue;
79
160
  }
80
- const date = parseLocalDay(first.date);
81
- const previous = index > 0 && weeks[index - 1]?.[0]
82
- ? parseLocalDay(weeks[index - 1][0].date)
83
- : null;
84
- const changedMonth = !previous || date.getMonth() !== previous.getMonth();
85
- if (!changedMonth) {
86
- return ''.padEnd(cellWidth, ' ');
161
+ const start = weekIndex * cellWidth;
162
+ for (let index = 0; index < label.length && start + index < cells.length; index += 1) {
163
+ cells[start + index] = label[index];
87
164
  }
88
- const label = monthLabels[date.getMonth()];
89
- return cellWidth >= 3 ? label : label.slice(0, cellWidth).padEnd(cellWidth, ' ');
90
- });
165
+ }
91
166
  return cells.join('');
92
167
  }
93
168
  function parseLocalDay(value) {
94
169
  const [year = '0', month = '1', day = '1'] = value.split('-');
95
170
  return new Date(Number(year), Number(month) - 1, Number(day));
96
171
  }
172
+ function localDay(date) {
173
+ const year = date.getFullYear();
174
+ const month = String(date.getMonth() + 1).padStart(2, '0');
175
+ const day = String(date.getDate()).padStart(2, '0');
176
+ return `${year}-${month}-${day}`;
177
+ }
178
+ function addLocalDays(date, days) {
179
+ const next = new Date(date.getTime());
180
+ next.setDate(next.getDate() + days);
181
+ return new Date(next.getFullYear(), next.getMonth(), next.getDate());
182
+ }
97
183
  function formatDays(days) {
98
184
  return `${days} ${days === 1 ? 'day' : 'days'}`;
99
185
  }
@@ -6,7 +6,8 @@ import { HelpScreen } from './HelpScreen.js';
6
6
  import { NowPlayingScreen } from './NowPlayingScreen.js';
7
7
  import { SettingsScreen } from './SettingsScreen.js';
8
8
  import { ExploreScreen } from './ExploreScreen.js';
9
- import { contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
9
+ import { CountriesScreen } from './CountriesScreen.js';
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';
12
13
  const station = {
@@ -82,8 +83,22 @@ describe('Explore world map rendering', () => {
82
83
  expect(/[⠀-⣿]/.test(frame)).toBe(false);
83
84
  });
84
85
  });
86
+ describe('CountriesScreen rendering', () => {
87
+ it('keeps long country rows to one terminal line', () => {
88
+ const frame = render(_jsx(CountriesScreen, { countries: [
89
+ {
90
+ name: 'The Extremely Long Democratic Republic Of The Country With A Very Long Name',
91
+ code: 'TL',
92
+ stationCount: 123456789
93
+ }
94
+ ], selected: 0, loading: false, filter: "", editingFilter: false, theme: "green", pageSize: 1, width: 48 })).lastFrame() ?? '';
95
+ expect(frame).toContain('>');
96
+ expect(frame).toContain('…');
97
+ expect(frame).not.toContain('Very Long Name');
98
+ });
99
+ });
85
100
  describe('StatsScreen rendering', () => {
86
- it('renders activity heatmap days as square markers instead of stretched blocks', () => {
101
+ it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
87
102
  const library = {
88
103
  recent: [],
89
104
  favorites: [],
@@ -104,9 +119,56 @@ describe('StatsScreen rendering', () => {
104
119
  settings
105
120
  };
106
121
  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('');
122
+ expect(frame).toContain('Jan');
108
123
  expect(frame).not.toContain('██');
109
124
  });
125
+ it('starts the contribution graph at January and uses large cells when space allows', () => {
126
+ const graph = buildContributionGraph([
127
+ { date: '2026-01-01', seconds: 3600 },
128
+ { date: '2026-12-31', seconds: 0 }
129
+ ], 170);
130
+ expect(graph.months.startsWith('Jan')).toBe(true);
131
+ expect(graph.months).toContain('Dec');
132
+ expect(graph.cellText).toBe(' ');
133
+ expect(graph.cellGap).toBe('');
134
+ expect(graph.tileHeight).toBe(2);
135
+ expect(graph.rows[4]?.cells[0]).toMatchObject({
136
+ level: 4,
137
+ text: ' ',
138
+ visible: true
139
+ });
140
+ expect(graph.rows[0]?.cells[0]).toMatchObject({
141
+ level: 0,
142
+ text: ' ',
143
+ visible: true
144
+ });
145
+ expect(graph.rows[6]?.cells.at(-1)).toMatchObject({
146
+ level: 0,
147
+ text: ' ',
148
+ visible: true
149
+ });
150
+ const normalWidthGraph = buildContributionGraph([
151
+ { date: '2026-01-01', seconds: 3600 },
152
+ { date: '2026-12-31', seconds: 0 }
153
+ ], 128);
154
+ expect(normalWidthGraph.cellText).toBe(' ');
155
+ expect(normalWidthGraph.cellGap).toBe('');
156
+ const compactWidthGraph = buildContributionGraph([
157
+ { date: '2026-01-01', seconds: 3600 },
158
+ { date: '2026-12-31', seconds: 0 }
159
+ ], 80);
160
+ expect(compactWidthGraph.cellText).toBe(' ');
161
+ expect(compactWidthGraph.cellGap).toBe('');
162
+ });
163
+ it('uses one-line heatmap cells when the stats panel is height constrained', () => {
164
+ const graph = buildContributionGraph([
165
+ { date: '2026-01-01', seconds: 3600 },
166
+ { date: '2026-12-31', seconds: 0 }
167
+ ], 170, 20);
168
+ expect(graph.cellText).toBe(' ');
169
+ expect(graph.cellGap).toBe('');
170
+ expect(graph.tileHeight).toBe(1);
171
+ });
110
172
  it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
111
173
  const days = [
112
174
  ...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
package/dist/ui/theme.js CHANGED
@@ -5,7 +5,10 @@ export const panelBackground = '#090d14';
5
5
  export const panelBorder = '#28313c';
6
6
  // Shared neutral palette. Use these named tokens instead of ad-hoc hex literals
7
7
  // so secondary text, rules, and map shading stay consistent across every screen.
8
- 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, searchEditingArrowAction, shouldHandleKeyboardEvent } from './app-state.js';
4
+ import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldToggleNearbyLocationShortcut } 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 }) {
@@ -256,7 +256,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
256
256
  toggleRadioGarden();
257
257
  return;
258
258
  }
259
- if (input === 'l') {
259
+ if (shouldToggleNearbyLocationShortcut(input, screen)) {
260
260
  toggleNearbyLocation();
261
261
  return;
262
262
  }
@@ -303,6 +303,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
303
303
  return;
304
304
  }
305
305
  if (input === 'b' || key.escape) {
306
+ if (screen === 'stations') {
307
+ go('countries');
308
+ return;
309
+ }
306
310
  go('home');
307
311
  return;
308
312
  }