@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.
- package/CHANGELOG.md +42 -0
- package/README.md +2 -2
- package/dist/activity/stats.js +4 -3
- package/dist/storage/store.js +7 -5
- package/dist/types.js +1 -1
- package/dist/ui/App.js +73 -11
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +21 -0
- package/dist/ui/components/Logo.js +2 -1
- package/dist/ui/components/Menu.js +2 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +4 -4
- package/dist/ui/components/TopTabs.js +3 -3
- package/dist/ui/layout.js +2 -1
- package/dist/ui/page-footer.js +5 -4
- package/dist/ui/screens/AirPlayCodeScreen.js +3 -3
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -4
- package/dist/ui/screens/CountriesScreen.js +8 -2
- package/dist/ui/screens/ExploreScreen.js +2 -2
- package/dist/ui/screens/HelpScreen.js +2 -2
- package/dist/ui/screens/HomeScreen.js +2 -2
- package/dist/ui/screens/MapScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +5 -5
- package/dist/ui/screens/SearchScreen.js +2 -2
- package/dist/ui/screens/SettingsScreen.js +3 -3
- package/dist/ui/screens/StationScreen.js +2 -1
- package/dist/ui/screens/StatsScreen.js +115 -29
- package/dist/ui/screens/screen-render.test.js +65 -3
- package/dist/ui/theme.js +6 -3
- package/dist/ui/use-app-input.js +6 -2
- package/dist/ui/visualizers/receiver-visualizers.js +94 -75
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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] ??
|
|
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:
|
|
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 :
|
|
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) :
|
|
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:
|
|
18
|
-
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
32
|
-
const weeks =
|
|
33
|
-
const
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 '#
|
|
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, '#
|
|
77
|
+
return [emptyContribution, '#3e1676', '#542799', '#763fcd', '#8c50f0'];
|
|
75
78
|
}
|
|
76
79
|
if (theme === 'copper') {
|
|
77
80
|
return [emptyContribution, '#44281f', '#7f4f37', '#b86f52', '#d08770'];
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { useInput } from 'ink';
|
|
3
3
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
4
|
-
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, 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
|
|
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
|
}
|