@ciphore/radiocli 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -2
- package/dist/activity/stats.js +4 -3
- package/dist/storage/store.js +3 -0
- package/dist/types.js +1 -1
- package/dist/ui/App.js +37 -8
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +27 -0
- package/dist/ui/components/Logo.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 +2 -2
- package/dist/ui/page-footer.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +3 -3
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -4
- package/dist/ui/screens/CountriesScreen.js +2 -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 +112 -32
- package/dist/ui/screens/screen-render.test.js +69 -0
- package/dist/ui/theme.js +6 -3
- package/dist/ui/use-app-input.js +15 -7
- package/dist/ui/visualizers/receiver-visualizers.js +94 -75
- package/package.json +1 -1
|
@@ -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,11 +1,20 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
2
3
|
import { Box, Text } from 'ink';
|
|
3
4
|
import { computeListeningStats } from '../../activity/stats.js';
|
|
4
|
-
import { panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
|
|
5
|
+
import { panelBorder, textHighlight, textMuted, themeAccent, themeContributionColors } from '../theme.js';
|
|
5
6
|
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
6
7
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
7
8
|
import { truncate } from '../format.js';
|
|
8
9
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
10
|
+
const dayLabelWidth = 5;
|
|
11
|
+
const heatmapCellOptions = [
|
|
12
|
+
{ cell: ' ', gap: ' ' },
|
|
13
|
+
{ cell: ' ', gap: '' },
|
|
14
|
+
{ cell: '■', gap: '' }
|
|
15
|
+
];
|
|
16
|
+
const compactHeatmapCell = '■';
|
|
17
|
+
const compactHeatmapGap = '';
|
|
9
18
|
export function StatsScreen({ library, theme, width, height }) {
|
|
10
19
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
11
20
|
const stats = computeListeningStats(library.activity.sessions);
|
|
@@ -17,71 +26,142 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
17
26
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
18
27
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
19
28
|
const compact = height < 30;
|
|
20
|
-
|
|
29
|
+
const tileHeight = graph.cellText.trim().length === 0 ? 2 : 1;
|
|
30
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: themeAccent(theme), bold: true, children: ["Activity \u2014 ", graph.year] }) }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), row.cells.map(cell => renderContributionCell(cell, graphColors, graph.cellGap))] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
|
|
21
31
|
? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
|
|
22
32
|
: 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
|
|
23
33
|
}
|
|
34
|
+
function renderContributionCell(cell, graphColors, cellGap) {
|
|
35
|
+
if (!cell.visible) {
|
|
36
|
+
return _jsx(Text, { children: cell.text }, cell.key);
|
|
37
|
+
}
|
|
38
|
+
const color = graphColors[cell.level] ?? graphColors[0] ?? textMuted;
|
|
39
|
+
if (cell.text.trim().length === 0) {
|
|
40
|
+
return (_jsxs(React.Fragment, { children: [_jsx(Text, { backgroundColor: color, children: cell.text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }, cell.key));
|
|
41
|
+
}
|
|
42
|
+
return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: color, children: cell.text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }, cell.key));
|
|
43
|
+
}
|
|
44
|
+
function renderLegendCell(color, text, cellGap) {
|
|
45
|
+
if (text.trim().length === 0) {
|
|
46
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: color, children: text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }));
|
|
47
|
+
}
|
|
48
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: color, children: text }), cellGap ? _jsx(Text, { children: cellGap }) : null] }));
|
|
49
|
+
}
|
|
24
50
|
function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, theme) {
|
|
25
51
|
const accent = themeAccent(theme);
|
|
26
52
|
const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
|
|
27
|
-
return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color:
|
|
53
|
+
return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: textMuted, children: [leftLabel, ": "] }), _jsx(Text, { color: accent, children: leftValue })] }) }), _jsx(Text, { children: ' '.repeat(leftPadding > 2 ? 2 : leftPadding) }), _jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: textMuted, children: [rightLabel, ": "] }), _jsx(Text, { color: accent, children: rightValue })] }) })] }));
|
|
28
54
|
}
|
|
29
|
-
function buildContributionGraph(days, width) {
|
|
30
|
-
const
|
|
31
|
-
const weeks =
|
|
32
|
-
const
|
|
55
|
+
export function buildContributionGraph(days, width) {
|
|
56
|
+
const year = graphYear(days);
|
|
57
|
+
const weeks = calendarYearWeeks(year);
|
|
58
|
+
const largestCell = heatmapCellOptions.find(option => dayLabelWidth + weeks.length * (option.cell.length + option.gap.length) <= width);
|
|
59
|
+
const selectedCell = largestCell ?? { cell: compactHeatmapCell, gap: compactHeatmapGap };
|
|
60
|
+
const largeCellWidth = selectedCell.cell.length + selectedCell.gap.length;
|
|
61
|
+
const compactCellWidth = compactHeatmapCell.length + compactHeatmapGap.length;
|
|
62
|
+
const cellWidth = largeCellWidth > 0 ? largeCellWidth : compactCellWidth;
|
|
63
|
+
const cellText = selectedCell.cell;
|
|
64
|
+
const cellGap = selectedCell.gap;
|
|
65
|
+
const secondsByDate = new Map(days.map(day => [day.date, day.seconds]));
|
|
66
|
+
const yearDays = days.filter(day => parseLocalDay(day.date).getFullYear() === year);
|
|
67
|
+
const scaleSeconds = contributionScaleSeconds(yearDays);
|
|
33
68
|
const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
|
|
34
69
|
const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
|
|
35
70
|
key: `day-${dayIndex}`,
|
|
36
71
|
label: labels[dayIndex] ?? '',
|
|
37
72
|
cells: weeks.map(week => {
|
|
38
73
|
const day = week[dayIndex];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
74
|
+
if (!day) {
|
|
75
|
+
return {
|
|
76
|
+
key: `empty-${dayIndex}`,
|
|
77
|
+
level: 0,
|
|
78
|
+
text: ' '.repeat(cellWidth),
|
|
79
|
+
visible: false
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const date = localDay(day.date);
|
|
83
|
+
const level = day.visible ? contributionLevel(secondsByDate.get(date) ?? 0, scaleSeconds) : 0;
|
|
84
|
+
return { key: date, level, text: cellText, visible: true };
|
|
42
85
|
})
|
|
43
86
|
}));
|
|
44
|
-
return { months: monthLine(weeks, cellWidth), rows };
|
|
87
|
+
return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, rows };
|
|
88
|
+
}
|
|
89
|
+
export function contributionScaleSeconds(days) {
|
|
90
|
+
const activeSeconds = days
|
|
91
|
+
.map(day => day.seconds)
|
|
92
|
+
.filter(seconds => seconds > 0)
|
|
93
|
+
.sort((left, right) => left - right);
|
|
94
|
+
if (activeSeconds.length === 0) {
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const percentileIndex = Math.min(activeSeconds.length - 1, Math.ceil(activeSeconds.length * 0.95) - 1);
|
|
98
|
+
return Math.max(1, activeSeconds[percentileIndex] ?? activeSeconds[activeSeconds.length - 1] ?? 1);
|
|
45
99
|
}
|
|
46
|
-
function contributionLevel(seconds,
|
|
100
|
+
export function contributionLevel(seconds, scaleSeconds) {
|
|
47
101
|
if (seconds <= 0) {
|
|
48
102
|
return 0;
|
|
49
103
|
}
|
|
50
|
-
const ratio = seconds /
|
|
51
|
-
if (ratio
|
|
104
|
+
const ratio = seconds / Math.max(1, scaleSeconds);
|
|
105
|
+
if (ratio >= 0.75) {
|
|
52
106
|
return 4;
|
|
53
107
|
}
|
|
54
|
-
if (ratio
|
|
108
|
+
if (ratio >= 0.5) {
|
|
55
109
|
return 3;
|
|
56
110
|
}
|
|
57
|
-
if (ratio
|
|
111
|
+
if (ratio >= 0.25) {
|
|
58
112
|
return 2;
|
|
59
113
|
}
|
|
60
114
|
return 1;
|
|
61
115
|
}
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
116
|
+
function graphYear(days) {
|
|
117
|
+
const lastDay = days[days.length - 1];
|
|
118
|
+
return lastDay ? parseLocalDay(lastDay.date).getFullYear() : new Date().getFullYear();
|
|
119
|
+
}
|
|
120
|
+
function calendarYearWeeks(year) {
|
|
121
|
+
const yearStart = new Date(year, 0, 1);
|
|
122
|
+
const yearEnd = new Date(year, 11, 31);
|
|
123
|
+
const gridStart = addLocalDays(yearStart, -yearStart.getDay());
|
|
124
|
+
const gridEnd = addLocalDays(yearEnd, 6 - yearEnd.getDay());
|
|
125
|
+
const weeks = [];
|
|
126
|
+
for (let cursor = gridStart; cursor.getTime() <= gridEnd.getTime(); cursor = addLocalDays(cursor, 7)) {
|
|
127
|
+
weeks.push(Array.from({ length: 7 }, (_, dayIndex) => {
|
|
128
|
+
const date = addLocalDays(cursor, dayIndex);
|
|
129
|
+
return { date, visible: date.getFullYear() === year };
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
return weeks;
|
|
133
|
+
}
|
|
134
|
+
function monthLine(weeks, cellWidth, year) {
|
|
135
|
+
const cells = Array.from({ length: weeks.length * cellWidth }, () => ' ');
|
|
136
|
+
for (let month = 0; month < 12; month += 1) {
|
|
137
|
+
const label = monthLabels[month];
|
|
138
|
+
const firstOfMonth = localDay(new Date(year, month, 1));
|
|
139
|
+
const weekIndex = weeks.findIndex(week => week.some(day => localDay(day.date) === firstOfMonth));
|
|
140
|
+
if (weekIndex < 0) {
|
|
141
|
+
continue;
|
|
67
142
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: null;
|
|
72
|
-
const changedMonth = !previous || date.getMonth() !== previous.getMonth();
|
|
73
|
-
if (!changedMonth) {
|
|
74
|
-
return ''.padEnd(cellWidth, ' ');
|
|
143
|
+
const start = weekIndex * cellWidth;
|
|
144
|
+
for (let index = 0; index < label.length && start + index < cells.length; index += 1) {
|
|
145
|
+
cells[start + index] = label[index];
|
|
75
146
|
}
|
|
76
|
-
|
|
77
|
-
return cellWidth >= 3 ? label : label.slice(0, cellWidth).padEnd(cellWidth, ' ');
|
|
78
|
-
});
|
|
147
|
+
}
|
|
79
148
|
return cells.join('');
|
|
80
149
|
}
|
|
81
150
|
function parseLocalDay(value) {
|
|
82
151
|
const [year = '0', month = '1', day = '1'] = value.split('-');
|
|
83
152
|
return new Date(Number(year), Number(month) - 1, Number(day));
|
|
84
153
|
}
|
|
154
|
+
function localDay(date) {
|
|
155
|
+
const year = date.getFullYear();
|
|
156
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
157
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
158
|
+
return `${year}-${month}-${day}`;
|
|
159
|
+
}
|
|
160
|
+
function addLocalDays(date, days) {
|
|
161
|
+
const next = new Date(date.getTime());
|
|
162
|
+
next.setDate(next.getDate() + days);
|
|
163
|
+
return new Date(next.getFullYear(), next.getMonth(), next.getDate());
|
|
164
|
+
}
|
|
85
165
|
function formatDays(days) {
|
|
86
166
|
return `${days} ${days === 1 ? 'day' : 'days'}`;
|
|
87
167
|
}
|
|
@@ -6,6 +6,7 @@ import { HelpScreen } from './HelpScreen.js';
|
|
|
6
6
|
import { NowPlayingScreen } from './NowPlayingScreen.js';
|
|
7
7
|
import { SettingsScreen } from './SettingsScreen.js';
|
|
8
8
|
import { ExploreScreen } from './ExploreScreen.js';
|
|
9
|
+
import { buildContributionGraph, contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
|
|
9
10
|
import { settingsItems } from '../screen-items.js';
|
|
10
11
|
import { defaultExploreCursor } from '../app-state.js';
|
|
11
12
|
const station = {
|
|
@@ -81,6 +82,74 @@ describe('Explore world map rendering', () => {
|
|
|
81
82
|
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
82
83
|
});
|
|
83
84
|
});
|
|
85
|
+
describe('StatsScreen rendering', () => {
|
|
86
|
+
it('renders activity heatmap as large calendar-year cells with readable month labels', () => {
|
|
87
|
+
const library = {
|
|
88
|
+
recent: [],
|
|
89
|
+
favorites: [],
|
|
90
|
+
imported: [],
|
|
91
|
+
trackHistory: [],
|
|
92
|
+
searchHistory: [],
|
|
93
|
+
activity: {
|
|
94
|
+
sessions: [
|
|
95
|
+
{
|
|
96
|
+
id: 'listen',
|
|
97
|
+
station,
|
|
98
|
+
startedAt: new Date(2026, 4, 24, 12).toISOString(),
|
|
99
|
+
endedAt: new Date(2026, 4, 24, 13).toISOString(),
|
|
100
|
+
listenedSeconds: 3600
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
settings
|
|
105
|
+
};
|
|
106
|
+
const frame = render(_jsx(DisplayContext.Provider, { value: resolveDisplayMode({ asciiMode: false }, {}), children: _jsx(StatsScreen, { library: library, theme: "green", width: 132, height: 32 }) })).lastFrame() ?? '';
|
|
107
|
+
expect(frame).toContain('Jan');
|
|
108
|
+
expect(frame).not.toContain('██');
|
|
109
|
+
});
|
|
110
|
+
it('starts the contribution graph at January and uses large cells when space allows', () => {
|
|
111
|
+
const graph = buildContributionGraph([
|
|
112
|
+
{ date: '2026-01-01', seconds: 3600 },
|
|
113
|
+
{ date: '2026-12-31', seconds: 0 }
|
|
114
|
+
], 170);
|
|
115
|
+
expect(graph.months.startsWith('Jan')).toBe(true);
|
|
116
|
+
expect(graph.months).toContain('Dec');
|
|
117
|
+
expect(graph.cellText).toBe(' ');
|
|
118
|
+
expect(graph.cellGap).toBe(' ');
|
|
119
|
+
expect(graph.rows[4]?.cells[0]).toMatchObject({
|
|
120
|
+
level: 4,
|
|
121
|
+
text: ' ',
|
|
122
|
+
visible: true
|
|
123
|
+
});
|
|
124
|
+
expect(graph.rows[0]?.cells[0]).toMatchObject({
|
|
125
|
+
level: 0,
|
|
126
|
+
text: ' ',
|
|
127
|
+
visible: true
|
|
128
|
+
});
|
|
129
|
+
expect(graph.rows[6]?.cells.at(-1)).toMatchObject({
|
|
130
|
+
level: 0,
|
|
131
|
+
text: ' ',
|
|
132
|
+
visible: true
|
|
133
|
+
});
|
|
134
|
+
const normalWidthGraph = buildContributionGraph([
|
|
135
|
+
{ date: '2026-01-01', seconds: 3600 },
|
|
136
|
+
{ date: '2026-12-31', seconds: 0 }
|
|
137
|
+
], 128);
|
|
138
|
+
expect(normalWidthGraph.cellText).toBe(' ');
|
|
139
|
+
expect(normalWidthGraph.cellGap).toBe('');
|
|
140
|
+
});
|
|
141
|
+
it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
|
|
142
|
+
const days = [
|
|
143
|
+
...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
|
|
144
|
+
{ date: '2026-05-20', seconds: 36_000 }
|
|
145
|
+
];
|
|
146
|
+
const scaleSeconds = contributionScaleSeconds(days);
|
|
147
|
+
expect(scaleSeconds).toBe(3600);
|
|
148
|
+
expect(contributionLevel(3600, scaleSeconds)).toBe(4);
|
|
149
|
+
expect(contributionLevel(900, scaleSeconds)).toBe(2);
|
|
150
|
+
expect(contributionLevel(0, scaleSeconds)).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
84
153
|
describe('HelpScreen rendering', () => {
|
|
85
154
|
it('lists keybinding sections and : commands', () => {
|
|
86
155
|
const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
|
package/dist/ui/theme.js
CHANGED
|
@@ -5,7 +5,10 @@ export const panelBackground = '#090d14';
|
|
|
5
5
|
export const panelBorder = '#28313c';
|
|
6
6
|
// Shared neutral palette. Use these named tokens instead of ad-hoc hex literals
|
|
7
7
|
// so secondary text, rules, and map shading stay consistent across every screen.
|
|
8
|
-
|
|
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, shouldHandleKeyboardEvent } from './app-state.js';
|
|
4
|
+
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent } from './app-state.js';
|
|
5
5
|
import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
|
|
6
6
|
import { completeCommand } from './help-content.js';
|
|
7
7
|
export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
|
|
@@ -138,12 +138,20 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
138
138
|
setEditingSearch(false);
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
const arrowAction = searchEditingArrowAction(key, currentItemCount('search') > 0);
|
|
142
|
+
if (arrowAction) {
|
|
143
|
+
if (arrowAction === 'history-older') {
|
|
144
|
+
recallSearchHistory('older');
|
|
145
|
+
}
|
|
146
|
+
else if (arrowAction === 'history-newer') {
|
|
147
|
+
recallSearchHistory('newer');
|
|
148
|
+
}
|
|
149
|
+
else if (arrowAction === 'select-previous') {
|
|
150
|
+
setSelected(value => clamp(value - 1, currentItemCount(screen) - 1));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
setSelected(value => clamp(value + 1, currentItemCount(screen) - 1));
|
|
154
|
+
}
|
|
147
155
|
return;
|
|
148
156
|
}
|
|
149
157
|
if (isEditableInput(input, key)) {
|
|
@@ -3,8 +3,8 @@ export function buildVisualizer(style, pulse, width, height, _station, playback,
|
|
|
3
3
|
if (!playbackHasSignal(playback)) {
|
|
4
4
|
return buildZeroSignalVisualizer(width, height, theme);
|
|
5
5
|
}
|
|
6
|
-
if (style === '
|
|
7
|
-
return
|
|
6
|
+
if (style === 'ultracode') {
|
|
7
|
+
return buildUltracode(pulse, width, height, theme);
|
|
8
8
|
}
|
|
9
9
|
if (style === 'motion-blob') {
|
|
10
10
|
return buildMotionBlob(pulse, width, height, theme);
|
|
@@ -153,7 +153,7 @@ export function buildVisualizer(style, pulse, width, height, _station, playback,
|
|
|
153
153
|
if (style === 'newton') {
|
|
154
154
|
return buildNewton(pulse, width, height, theme);
|
|
155
155
|
}
|
|
156
|
-
return
|
|
156
|
+
return buildUltracode(pulse, width, height, theme);
|
|
157
157
|
}
|
|
158
158
|
function playbackHasSignal(playback) {
|
|
159
159
|
return playback.state === 'playing' && playback.ready;
|
|
@@ -172,71 +172,80 @@ function buildFlatZeroSignal(width, requestedHeight, theme) {
|
|
|
172
172
|
color: rowIndex === height - 1 ? accent : '#767676'
|
|
173
173
|
}));
|
|
174
174
|
}
|
|
175
|
-
|
|
175
|
+
const ultracodeWavelength = 20;
|
|
176
|
+
const ultracodeTravelPerPulse = 80 * 0.03;
|
|
177
|
+
const ultracodeVioletRamp = ['#3e1676', '#491e87', '#542799', '#5f2faa', '#6b37bc', '#763fcd', '#8148df', '#8c50f0'];
|
|
178
|
+
function buildUltracode(pulse, width, height, theme) {
|
|
179
|
+
const renderWidth = Math.max(1, width);
|
|
180
|
+
const renderHeight = Math.max(1, height);
|
|
176
181
|
const accent = themeAccent(theme);
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const p = (pulse - k + 240) % 240;
|
|
191
|
-
const low = Math.sin(i * 0.18 + p * 0.46);
|
|
192
|
-
const mid = Math.cos(i * 0.32 - p * 0.28);
|
|
193
|
-
const high = Math.sin(i * 0.45 + p * 0.68);
|
|
194
|
-
const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
|
|
195
|
-
const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
|
|
196
|
-
const lvl = Math.round(eased * height);
|
|
197
|
-
if (lvl > maxLvl) {
|
|
198
|
-
maxLvl = lvl;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return maxLvl;
|
|
202
|
-
});
|
|
203
|
-
const rows = [];
|
|
204
|
-
const colors = themeContributionColors(theme);
|
|
205
|
-
for (let rowIndex = 0; rowIndex < height; rowIndex++) {
|
|
206
|
-
const threshold = height - rowIndex;
|
|
207
|
-
let rowText = ' ';
|
|
208
|
-
for (let i = 0; i < bandCount; i++) {
|
|
209
|
-
const lvl = levels[i];
|
|
210
|
-
const peak = peaks[i];
|
|
211
|
-
let bandChar = ' ';
|
|
212
|
-
if (lvl >= threshold) {
|
|
213
|
-
bandChar = '██';
|
|
214
|
-
}
|
|
215
|
-
else if (peak === threshold) {
|
|
216
|
-
bandChar = '◆◆';
|
|
217
|
-
}
|
|
218
|
-
else {
|
|
219
|
-
bandChar = '··';
|
|
220
|
-
}
|
|
221
|
-
rowText += bandChar + ' ';
|
|
222
|
-
}
|
|
223
|
-
let color = colors[2] ?? accent;
|
|
224
|
-
if (rowIndex < Math.max(1, height * 0.3)) {
|
|
225
|
-
color = '#ff5f87';
|
|
226
|
-
}
|
|
227
|
-
else if (rowIndex < Math.max(2, height * 0.6)) {
|
|
228
|
-
color = colors[4] ?? accent;
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
color = colors[3] ?? accent;
|
|
232
|
-
}
|
|
233
|
-
rows.push({
|
|
234
|
-
text: rowText.padEnd(width).slice(0, width),
|
|
235
|
-
color
|
|
182
|
+
const ramp = ultracodeRippleRamp(theme);
|
|
183
|
+
const selectedColor = ramp[ramp.length - 1] ?? accent;
|
|
184
|
+
const travel = pulse * ultracodeTravelPerPulse;
|
|
185
|
+
const originColumn = Math.floor(renderWidth / 2);
|
|
186
|
+
const originRow = Math.floor(renderHeight / 2);
|
|
187
|
+
const rows = Array.from({ length: renderHeight }, (_, rowIndex) => {
|
|
188
|
+
const cells = Array.from({ length: renderWidth }, (_, columnIndex) => {
|
|
189
|
+
const level = ultracodeRippleLevel(ultracodeDistance(columnIndex, rowIndex, originColumn, originRow), travel, ramp.length);
|
|
190
|
+
return {
|
|
191
|
+
text: ' ',
|
|
192
|
+
color: level === null ? accent : selectedColor,
|
|
193
|
+
backgroundColor: level === null ? undefined : ramp[level] ?? selectedColor
|
|
194
|
+
};
|
|
236
195
|
});
|
|
237
|
-
|
|
196
|
+
return lineFromCells(cells, accent);
|
|
197
|
+
});
|
|
238
198
|
return rows;
|
|
239
199
|
}
|
|
200
|
+
function ultracodeDistance(column, row, originColumn, originRow) {
|
|
201
|
+
const dx = column - originColumn;
|
|
202
|
+
const dy = (row - originRow) * 2;
|
|
203
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
204
|
+
}
|
|
205
|
+
function ultracodeRippleLevel(distance, travel, rampLength) {
|
|
206
|
+
if (distance > travel) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const phase = (((distance - travel) % ultracodeWavelength) + ultracodeWavelength) % ultracodeWavelength;
|
|
210
|
+
const brightness = (1 + Math.cos((2 * Math.PI * phase) / ultracodeWavelength)) / 2;
|
|
211
|
+
return Math.min(rampLength - 1, Math.round(brightness * (rampLength - 1)));
|
|
212
|
+
}
|
|
213
|
+
function ultracodeRippleRamp(theme) {
|
|
214
|
+
if (theme === 'violet') {
|
|
215
|
+
return ultracodeVioletRamp;
|
|
216
|
+
}
|
|
217
|
+
const colors = themeContributionColors(theme);
|
|
218
|
+
const start = colors[1] ?? '#1c1c1c';
|
|
219
|
+
const end = themeAccent(theme);
|
|
220
|
+
return Array.from({ length: 8 }, (_, index) => interpolateHex(start, end, index / 7));
|
|
221
|
+
}
|
|
222
|
+
function interpolateHex(startHex, endHex, amount) {
|
|
223
|
+
const start = hexToRgb(startHex);
|
|
224
|
+
const end = hexToRgb(endHex);
|
|
225
|
+
return rgbToHex([
|
|
226
|
+
Math.round(start[0] + (end[0] - start[0]) * amount),
|
|
227
|
+
Math.round(start[1] + (end[1] - start[1]) * amount),
|
|
228
|
+
Math.round(start[2] + (end[2] - start[2]) * amount)
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
function hexToRgb(hex) {
|
|
232
|
+
const normalized = hex.replace('#', '');
|
|
233
|
+
const expanded = normalized.length === 3
|
|
234
|
+
? [...normalized].map(value => value + value).join('')
|
|
235
|
+
: normalized.padEnd(6, '0').slice(0, 6);
|
|
236
|
+
const value = Number.parseInt(expanded, 16);
|
|
237
|
+
return [
|
|
238
|
+
(value >> 16) & 255,
|
|
239
|
+
(value >> 8) & 255,
|
|
240
|
+
value & 255
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
function rgbToHex([red, green, blue]) {
|
|
244
|
+
return `#${[red, green, blue].map(value => clampColor(value).toString(16).padStart(2, '0')).join('')}`;
|
|
245
|
+
}
|
|
246
|
+
function clampColor(value) {
|
|
247
|
+
return Math.max(0, Math.min(255, Math.round(value)));
|
|
248
|
+
}
|
|
240
249
|
function buildMotionBlob(pulse, width, height, theme) {
|
|
241
250
|
const h = Math.max(7, height);
|
|
242
251
|
const mid = (h - 1) / 2;
|
|
@@ -1876,11 +1885,19 @@ function lineFromCells(cells, fallbackColor) {
|
|
|
1876
1885
|
const segments = [];
|
|
1877
1886
|
for (const cell of cells) {
|
|
1878
1887
|
const previous = segments[segments.length - 1];
|
|
1879
|
-
if (previous &&
|
|
1888
|
+
if (previous &&
|
|
1889
|
+
previous.color === cell.color &&
|
|
1890
|
+
previous.backgroundColor === cell.backgroundColor &&
|
|
1891
|
+
previous.bold === cell.bold) {
|
|
1880
1892
|
previous.text += cell.text;
|
|
1881
1893
|
}
|
|
1882
1894
|
else {
|
|
1883
|
-
segments.push({
|
|
1895
|
+
segments.push({
|
|
1896
|
+
text: cell.text,
|
|
1897
|
+
color: cell.color,
|
|
1898
|
+
backgroundColor: cell.backgroundColor,
|
|
1899
|
+
bold: cell.bold
|
|
1900
|
+
});
|
|
1884
1901
|
}
|
|
1885
1902
|
}
|
|
1886
1903
|
return { text, color: fallbackColor, segments };
|
|
@@ -1938,7 +1955,7 @@ function dimMotionColorAt(position, theme) {
|
|
|
1938
1955
|
}
|
|
1939
1956
|
export function visualizerHeight(style, availableRows, width = 80) {
|
|
1940
1957
|
const maxRowsByStyle = {
|
|
1941
|
-
|
|
1958
|
+
ultracode: 14,
|
|
1942
1959
|
'motion-blob': 12,
|
|
1943
1960
|
'motion-area': 11,
|
|
1944
1961
|
'motion-contour': 14,
|
|
@@ -1990,7 +2007,7 @@ export function visualizerHeight(style, availableRows, width = 80) {
|
|
|
1990
2007
|
newton: 14
|
|
1991
2008
|
};
|
|
1992
2009
|
const spaciousStyles = new Set([
|
|
1993
|
-
'
|
|
2010
|
+
'ultracode',
|
|
1994
2011
|
'motion-blob',
|
|
1995
2012
|
'motion-area',
|
|
1996
2013
|
'motion-contour',
|
|
@@ -2041,13 +2058,15 @@ export function visualizerHeight(style, availableRows, width = 80) {
|
|
|
2041
2058
|
'lava-lamp',
|
|
2042
2059
|
'newton'
|
|
2043
2060
|
]);
|
|
2044
|
-
const minRows = style === '
|
|
2045
|
-
?
|
|
2046
|
-
:
|
|
2047
|
-
?
|
|
2048
|
-
:
|
|
2049
|
-
?
|
|
2050
|
-
:
|
|
2061
|
+
const minRows = style === 'ultracode'
|
|
2062
|
+
? 7
|
|
2063
|
+
: style === 'cube' || style === 'motion-contour' || style === 'spinning-donut'
|
|
2064
|
+
? 8
|
|
2065
|
+
: spaciousStyles.has(style)
|
|
2066
|
+
? 7
|
|
2067
|
+
: style.startsWith('motion-')
|
|
2068
|
+
? 6
|
|
2069
|
+
: 3;
|
|
2051
2070
|
const baseMaxRows = maxRowsByStyle[style] ?? 8;
|
|
2052
2071
|
const maxRows = responsiveVisualizerMaxRows(style, availableRows, width, baseMaxRows, spaciousStyles);
|
|
2053
2072
|
return Math.max(minRows, Math.min(maxRows, availableRows));
|