@ciphore/radiocli 0.1.9 → 0.2.1

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +53 -3
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +179 -98
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +28 -8
  14. package/dist/providers/radio-browser.js +141 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +264 -149
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +489 -54
  20. package/dist/ui/AppContent.js +11 -12
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +10 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +15 -2
  35. package/dist/ui/screen-items.js +60 -21
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +109 -10
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +44 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +37 -6
  52. package/dist/ui/use-command-executor.js +34 -1
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/dist/update-check.js +122 -0
  56. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  57. package/package.json +2 -2
  58. package/dist/ui/screens/screen-render.test.js +0 -214
@@ -2,19 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { computeListeningStats } from '../../activity/stats.js';
5
- import { panelBorder, textHighlight, textMuted, themeAccent, themeContributionColors } from '../theme.js';
5
+ import { panelBorder, textMuted, themeAccent, themeContributionColors } from '../theme.js';
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { truncate } from '../format.js';
9
9
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
10
  const dayLabelWidth = 5;
11
- const heatmapCellOptions = [
12
- { cell: ' ', gap: '' },
13
- { cell: ' ', gap: '' }
14
- ];
15
- const compactHeatmapCell = ' ';
16
- const compactHeatmapGap = '';
17
- const tallHeatmapMinHeight = 26;
11
+ // Two terminal columns are approximately one row high in common monospace
12
+ // fonts, so 2x1 cells remain visually square. Narrow screens show fewer recent
13
+ // weeks instead of distorting the cells.
14
+ const heatmapCell = ' ';
15
+ const heatmapGap = '';
18
16
  export function StatsScreen({ library, theme, width, height }) {
19
17
  const { panel: panelBackground, ascii } = useDisplay();
20
18
  const stats = computeListeningStats(library.activity.sessions);
@@ -26,9 +24,7 @@ export function StatsScreen({ library, theme, width, height }) {
26
24
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
27
25
  const favoriteWidth = Math.max(8, metricWidth - 18);
28
26
  const compact = height < 30;
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
30
- ? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
31
- : 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
27
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 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}`, 'Stations counted after', '2 min', 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" })] })] })] }));
32
28
  }
33
29
  function renderContributionCells(cells, graphColors, cellGap) {
34
30
  const runs = [];
@@ -69,20 +65,17 @@ function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, t
69
65
  const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
70
66
  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 })] }) })] }));
71
67
  }
72
- export function buildContributionGraph(days, width, height = tallHeatmapMinHeight) {
68
+ export function buildContributionGraph(days, width, _height = 26) {
73
69
  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;
70
+ const endDate = days.length > 0 ? parseLocalDay(days[days.length - 1].date) : new Date();
71
+ const availableWeekCount = Math.max(1, Math.floor((width - dayLabelWidth) / heatmapCell.length));
72
+ const weeks = rollingCalendarWeeks(endDate, availableWeekCount);
73
+ const cellWidth = heatmapCell.length;
74
+ const cellText = heatmapCell;
75
+ const cellGap = heatmapGap;
76
+ const tileHeight = 1;
83
77
  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);
78
+ const scaleSeconds = contributionScaleSeconds(days);
86
79
  const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
87
80
  const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
88
81
  key: `day-${dayIndex}`,
@@ -99,10 +92,10 @@ export function buildContributionGraph(days, width, height = tallHeatmapMinHeigh
99
92
  }
100
93
  const date = localDay(day.date);
101
94
  const level = day.visible ? contributionLevel(secondsByDate.get(date) ?? 0, scaleSeconds) : 0;
102
- return { key: date, level, text: cellText, visible: true };
95
+ return { key: date, level, text: cellText, visible: day.visible };
103
96
  })
104
97
  }));
105
- return { year, months: monthLine(weeks, cellWidth, year), cellText, cellGap, tileHeight, rows };
98
+ return { year, months: monthLine(weeks, cellWidth), cellText, cellGap, tileHeight, rows };
106
99
  }
107
100
  export function contributionScaleSeconds(days) {
108
101
  const activeSeconds = days
@@ -135,29 +128,27 @@ function graphYear(days) {
135
128
  const lastDay = days[days.length - 1];
136
129
  return lastDay ? parseLocalDay(lastDay.date).getFullYear() : new Date().getFullYear();
137
130
  }
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;
131
+ function rollingCalendarWeeks(endDate, availableWeekCount) {
132
+ const gridEnd = addLocalDays(endDate, 6 - endDate.getDay());
133
+ const weekCount = Math.min(53, availableWeekCount);
134
+ const gridStart = addLocalDays(gridEnd, -(weekCount * 7 - 1));
135
+ return Array.from({ length: weekCount }, (_, weekIndex) => Array.from({ length: 7 }, (_, dayIndex) => {
136
+ const date = addLocalDays(gridStart, weekIndex * 7 + dayIndex);
137
+ return {
138
+ date,
139
+ visible: true
140
+ };
141
+ }));
151
142
  }
152
- function monthLine(weeks, cellWidth, year) {
143
+ function monthLine(weeks, cellWidth) {
153
144
  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) {
145
+ for (let weekIndex = 0; weekIndex < weeks.length; weekIndex += 1) {
146
+ const week = weeks[weekIndex];
147
+ const firstOfMonth = week?.find(day => day.date.getDate() === 1);
148
+ if (!firstOfMonth) {
159
149
  continue;
160
150
  }
151
+ const label = monthLabels[firstOfMonth.date.getMonth()];
161
152
  const start = weekIndex * cellWidth;
162
153
  for (let index = 0; index < label.length && start + index < cells.length; index += 1) {
163
154
  cells[start + index] = label[index];
@@ -1,13 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { resolveCommand } from '../player/command.js';
3
+ import { safeExternalHttpUrl } from '../safety.js';
3
4
  // The command used to open a URL in the platform's default handler.
4
5
  export function openExternalCommand(platform = process.platform) {
5
6
  if (platform === 'darwin') {
6
7
  return { command: 'open', args: [] };
7
8
  }
8
9
  if (platform === 'win32') {
9
- // The empty "" is the window title argument `start` expects before the URL.
10
- return { command: 'cmd', args: ['/c', 'start', ''] };
10
+ // Direct invocation avoids cmd.exe interpreting URL query characters.
11
+ return { command: 'explorer', args: [] };
11
12
  }
12
13
  return { command: 'xdg-open', args: [] };
13
14
  }
@@ -27,14 +28,20 @@ export function clipboardCommands(platform = process.platform) {
27
28
  ];
28
29
  }
29
30
  export function openExternal(url, platform = process.platform) {
31
+ const safeUrl = safeExternalHttpUrl(url);
32
+ if (!safeUrl) {
33
+ return false;
34
+ }
30
35
  const { command, args } = openExternalCommand(platform);
31
36
  try {
32
- const child = spawn(resolveCommand(command) ?? command, [...args, url], { stdio: 'ignore', detached: true });
37
+ const child = spawn(resolveCommand(command) ?? command, [...args, safeUrl], { stdio: 'ignore', detached: true });
33
38
  child.on('error', () => undefined);
34
39
  child.unref();
40
+ return true;
35
41
  }
36
42
  catch {
37
43
  // Opening a browser is best-effort; never crash the TUI over it.
44
+ return false;
38
45
  }
39
46
  }
40
47
  export function copyToClipboard(text, platform = process.platform) {
@@ -11,9 +11,53 @@ export function parseSgrMouseEvents(input) {
11
11
  pressed: match[4] === 'M'
12
12
  })).filter(event => Number.isFinite(event.button) && Number.isFinite(event.x) && Number.isFinite(event.y));
13
13
  }
14
+ /** Parse modern SGR mouse reports and the legacy X10 form used by older terminals. */
15
+ export function parseTerminalMouseEvents(input) {
16
+ const text = String(input);
17
+ const events = parseSgrMouseEvents(text);
18
+ if (!(input instanceof Uint8Array))
19
+ return events;
20
+ for (let index = 0; index <= input.length - 6; index += 1) {
21
+ if (input[index] !== 0x1b || input[index + 1] !== 0x5b || input[index + 2] !== 0x4d)
22
+ continue;
23
+ const button = (input[index + 3] ?? 32) - 32;
24
+ const x = (input[index + 4] ?? 32) - 32;
25
+ const y = (input[index + 5] ?? 32) - 32;
26
+ if (button < 0 || x < 1 || y < 1)
27
+ continue;
28
+ events.push({ button, x, y, pressed: (button & 3) !== 3 });
29
+ index += 5;
30
+ }
31
+ return events;
32
+ }
33
+ const selectableMouseScreens = new Set([
34
+ 'home', 'countries', 'map', 'stations', 'search', 'nearby', 'explore',
35
+ 'library', 'settings', 'help', 'airplay-settings'
36
+ ]);
37
+ export function shouldEnableMouseReporting(screen, itemCount, visibleRows, mouseSupport = true) {
38
+ if (!mouseSupport || !selectableMouseScreens.has(screen))
39
+ return false;
40
+ // Explore also needs mouse clicks for map placement when its station list is short.
41
+ return screen === 'explore' || itemCount > Math.max(0, visibleRows);
42
+ }
14
43
  export function primaryMousePress(events) {
15
44
  return events.find(event => event.pressed && (event.button & 3) === 0 && (event.button & 96) === 0) ?? null;
16
45
  }
46
+ export function wheelScrollDelta(events) {
47
+ return events.reduce((delta, event) => {
48
+ if (!event.pressed) {
49
+ return delta;
50
+ }
51
+ const button = event.button & 127;
52
+ if (button === 64) {
53
+ return delta - 1;
54
+ }
55
+ if (button === 65) {
56
+ return delta + 1;
57
+ }
58
+ return delta;
59
+ }, 0);
60
+ }
17
61
  export function exploreCursorForMouseCell(x, y, frameWidth, layout) {
18
62
  if (layout.compact) {
19
63
  return null;
package/dist/ui/theme.js CHANGED
@@ -9,7 +9,6 @@ export const panelBorder = '#28313c';
9
9
  // named gray maps to terminal-palette "bright black" and can become unreadable.
10
10
  export const textMuted = '#aab4c2';
11
11
  export const textDim = '#8a96a8';
12
- export const textHighlight = '#d8c66f';
13
12
  export const mapLand = '#33414f';
14
13
  export const mapWater = '#1a2430';
15
14
  export const mapMarker = '#7dd3fc';
@@ -2,9 +2,9 @@ import { useEffect } from 'react';
2
2
  import { useInput } from 'ink';
3
3
  import { homeItems, settingsItems } from './screen-items.js';
4
4
  import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldToggleNearbyLocationShortcut } from './app-state.js';
5
- import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
5
+ import { parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta } from './terminal-mouse.js';
6
6
  import { completeCommand } from './help-content.js';
7
- export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
7
+ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, confirmCtrlCExit, 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, toggleDirectoryVoting, toggleRadioGarden, toggleSkipBrokenStreams, updateFromSettings }) {
8
8
  useEffect(() => {
9
9
  const onData = (data) => {
10
10
  const rawInput = String(data);
@@ -20,10 +20,15 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
20
20
  saveLearnedTransportKey(capturingTransportAction, rawInput);
21
21
  return;
22
22
  }
23
- const mouseEvents = parseSgrMouseEvents(rawInput);
23
+ const mouseEvents = parseTerminalMouseEvents(data);
24
24
  if (mouseEvents.length > 0) {
25
+ const wheelDelta = wheelScrollDelta(mouseEvents);
25
26
  const click = primaryMousePress(mouseEvents);
26
27
  lastRawTransportAtRef.current = Date.now();
28
+ if (wheelDelta !== 0 && shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter)) {
29
+ setSelected(value => clamp(value + wheelDelta * 3, currentItemCount(screen) - 1));
30
+ return;
31
+ }
27
32
  if (!commandMode && screen === 'explore' && click) {
28
33
  moveExploreCursorToCell(click.x, click.y);
29
34
  }
@@ -70,7 +75,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
70
75
  ]);
71
76
  useInput((input, key) => {
72
77
  if (key.ctrl && input === 'c') {
73
- shutdown();
78
+ confirmCtrlCExit();
74
79
  return;
75
80
  }
76
81
  if (!shouldHandleKeyboardEvent(key.eventType)) {
@@ -289,8 +294,8 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
289
294
  setSelected(value => clamp(value - 10, currentItemCount(screen) - 1));
290
295
  return;
291
296
  }
292
- if (screen === 'home' && (/^[1-9]$/.test(input) || input === '0')) {
293
- const menuIndex = input === '0' ? 9 : Number(input) - 1;
297
+ if (screen === 'home' && /^[1-8]$/.test(input)) {
298
+ const menuIndex = Number(input) - 1;
294
299
  setSelected(menuIndex);
295
300
  const target = homeItems[menuIndex]?.screen;
296
301
  if (target) {
@@ -396,6 +401,9 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
396
401
  else if (item === 'Toggle nearby location lookup') {
397
402
  toggleNearbyLocation();
398
403
  }
404
+ else if (item === 'Share favorite votes with Radio Browser') {
405
+ toggleDirectoryVoting();
406
+ }
399
407
  else if (item === 'Audio output') {
400
408
  cycleAudioOutput();
401
409
  }
@@ -426,6 +434,20 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
426
434
  else if (item === 'Reduce motion') {
427
435
  toggleSetting('reduceMotion');
428
436
  }
437
+ else if (item === 'Mouse and trackpad scrolling') {
438
+ toggleSetting('mouseSupport');
439
+ }
440
+ else if (item === 'Export preferences and library') {
441
+ setCommandText('export ');
442
+ setCommandMode(true);
443
+ }
444
+ else if (item === 'Import preferences and library') {
445
+ setCommandText('import ');
446
+ setCommandMode(true);
447
+ }
448
+ else if (item === 'Check for updates') {
449
+ void updateFromSettings();
450
+ }
429
451
  else if (item === 'Refresh provider health') {
430
452
  refreshProviderHealth();
431
453
  setMessage('Provider health refreshed.');
@@ -461,6 +483,15 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
461
483
  }
462
484
  });
463
485
  }
486
+ function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
487
+ if (commandMode || editingCountryFilter) {
488
+ return false;
489
+ }
490
+ return [
491
+ 'home', 'countries', 'map', 'stations', 'search', 'nearby', 'explore',
492
+ 'library', 'settings', 'help', 'airplay-settings'
493
+ ].includes(screen);
494
+ }
464
495
  function exploreMoveForInput(input) {
465
496
  const normalized = input.toLowerCase();
466
497
  if (normalized === 'w') {
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
3
- export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
3
+ export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
4
4
  return useCallback(async (rawCommand) => {
5
5
  const trimmed = rawCommand.trim();
6
6
  if (!trimmed) {
@@ -136,6 +136,38 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
136
136
  go('settings');
137
137
  return;
138
138
  }
139
+ if (name === 'export' || name === 'backup') {
140
+ try {
141
+ const backupPath = store.exportBackup(value || undefined);
142
+ setMessage(`Backup exported: ${backupPath}`);
143
+ }
144
+ catch (error) {
145
+ setMessage(error instanceof Error ? error.message : 'Could not export the RadioCLI backup.');
146
+ }
147
+ return;
148
+ }
149
+ if (name === 'import' || name === 'restore') {
150
+ if (!value.trim()) {
151
+ setMessage('Usage: :import <backup.json>');
152
+ return;
153
+ }
154
+ try {
155
+ const result = store.importBackup(value);
156
+ settingsRef.current = result.state.settings;
157
+ setLibrary(result.state);
158
+ setMessage(result.safetyBackupPath
159
+ ? `Backup imported. Previous library saved to ${result.safetyBackupPath}`
160
+ : `Backup imported: ${result.sourcePath}`);
161
+ }
162
+ catch (error) {
163
+ setMessage(error instanceof Error ? error.message : 'Could not import the RadioCLI backup.');
164
+ }
165
+ return;
166
+ }
167
+ if (name === 'update') {
168
+ await updateCommand();
169
+ return;
170
+ }
139
171
  if (name === 'stop') {
140
172
  setLibrary(store.finishActiveListeningSession());
141
173
  await player.stop();
@@ -189,6 +221,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
189
221
  store,
190
222
  toggleFavorite,
191
223
  toggleMute,
224
+ updateCommand,
192
225
  updateSettings
193
226
  ]);
194
227
  }
@@ -0,0 +1,101 @@
1
+ /** Canonical receiver-style metadata used by rendering, sizing, UI cycling, and migration. */
2
+ export const receiverStyleRegistry = [
3
+ { id: 'ultracode', maxRows: 14, minRows: 7, resetPulse: true },
4
+ { id: 'pulse-grid', maxRows: 12, minRows: 7 },
5
+ { id: 'hex-pulse', maxRows: 12, minRows: 7 },
6
+ { id: 'moire', maxRows: 14, minRows: 7 },
7
+ { id: 'galaxy', maxRows: 14, minRows: 7 },
8
+ { id: 'cyclone', maxRows: 14, minRows: 7 },
9
+ { id: 'nebula', maxRows: 14, minRows: 7 },
10
+ { id: 'lava-lamp', maxRows: 14, minRows: 7 },
11
+ { id: 'motion-contour', maxRows: 14, minRows: 8 },
12
+ { id: 'ribbon', maxRows: 12, minRows: 7 },
13
+ { id: 'braille-wave', maxRows: 12, minRows: 7 },
14
+ { id: 'silk', maxRows: 14, minRows: 7 },
15
+ { id: 'lissajous', maxRows: 14, minRows: 7 },
16
+ { id: 'harmonograph', maxRows: 14, minRows: 7 },
17
+ { id: 'spirograph', maxRows: 14, minRows: 7 },
18
+ { id: 'goniometer', maxRows: 14, minRows: 7 },
19
+ { id: 'radial-eq', maxRows: 14, minRows: 7 },
20
+ { id: 'spectrogram', maxRows: 12, minRows: 7 },
21
+ { id: 'caustics', maxRows: 14, minRows: 7 },
22
+ { id: 'ripple-tank', maxRows: 14, minRows: 7 },
23
+ { id: 'leds', maxRows: 10, minRows: 7 },
24
+ { id: 'matrix', maxRows: 14, minRows: 7 },
25
+ { id: 'cascade', maxRows: 14, minRows: 7 },
26
+ { id: 'ordered-dither', maxRows: 14, minRows: 7 },
27
+ { id: 'xor-texture', maxRows: 14, minRows: 7 },
28
+ { id: 'pixel-crush', maxRows: 14, minRows: 7 },
29
+ { id: 'copper-bars', maxRows: 14, minRows: 7 },
30
+ { id: 'rotozoomer', maxRows: 14, minRows: 7 },
31
+ { id: 'mesh', maxRows: 14, minRows: 7 },
32
+ { id: 'hologram', maxRows: 12, minRows: 7 },
33
+ { id: 'mirror', maxRows: 12, minRows: 7 },
34
+ { id: 'tunnel', maxRows: 14, minRows: 7 },
35
+ { id: 'kaleidoscope', maxRows: 14, minRows: 7 },
36
+ { id: 'phosphene', maxRows: 14, minRows: 7 },
37
+ { id: 'phyllotaxis', maxRows: 14, minRows: 7 },
38
+ { id: 'chladni', maxRows: 14, minRows: 7 },
39
+ { id: 'newton', maxRows: 14, minRows: 7 },
40
+ { id: 'cube', maxRows: 14, minRows: 8 },
41
+ { id: 'spinning-donut', maxRows: 14, minRows: 8 },
42
+ { id: 'tesseract', maxRows: 14, minRows: 7 },
43
+ { id: 'torus-knot', maxRows: 14, minRows: 7 },
44
+ { id: 'twister', maxRows: 14, minRows: 7 },
45
+ { id: 'lorenz', maxRows: 14, minRows: 7 },
46
+ { id: 'fern', maxRows: 14, minRows: 7 },
47
+ { id: 'fractal-tree', maxRows: 14, minRows: 7 },
48
+ { id: 'julia', maxRows: 14, minRows: 7 },
49
+ { id: 'neural-net', maxRows: 14, minRows: 7 },
50
+ { id: 'constellation', maxRows: 12, minRows: 7 },
51
+ { id: 'starfield', maxRows: 14, minRows: 7 },
52
+ { id: 'fireworks', maxRows: 14, minRows: 7 },
53
+ { id: 'fire', maxRows: 14, minRows: 7 },
54
+ { id: 'aurora', maxRows: 14, minRows: 7 },
55
+ { id: 'stormfront', maxRows: 14, minRows: 7 },
56
+ { id: 'planetarium', maxRows: 14, minRows: 7 },
57
+ { id: 'sumi-ocean', maxRows: 14, minRows: 7 },
58
+ { id: 'liftoff', maxRows: 14, minRows: 8, resetPulse: true },
59
+ { id: 'flyover', maxRows: 14, minRows: 7 },
60
+ { id: 'skyline', maxRows: 14, minRows: 7 },
61
+ { id: 'golden-gate', maxRows: 14, minRows: 7 },
62
+ { id: 'manhattan', maxRows: 14, minRows: 7 },
63
+ { id: 'alpine', maxRows: 14, minRows: 7 }
64
+ ];
65
+ export const receiverStyleNames = receiverStyleRegistry.map(style => style.id);
66
+ export const defaultReceiverStyle = 'ultracode';
67
+ export const receiverStyleMetadata = Object.fromEntries(receiverStyleRegistry.map(style => [style.id, style]));
68
+ const retiredReceiverStyleNames = [
69
+ 'scope', 'spectrum', 'oscilloscope', 'sdr', 'signal', 'retro', 'neon', 'waterfall',
70
+ 'cassette', 'casette', 'stars', 'radio-waves', 'raindrops', 'vinyl', 'soundwave',
71
+ 'spectrum-3d', 'tuning-dial', 'rf-constellation', 'rf-constelation', 'sphere', 'mobius',
72
+ 's-meter', 'jellyfish', 'prism', 'motion-bars', 'motion-dots', 'motion-braid', 'radar',
73
+ 'dual-ripple', 'perspective-floor', 'bloom-bars', 'coral', 'bokeh', 'vector-balls',
74
+ 'smoke', 'running-horse', 'tesla-arcs', 'isometric', 'clockwork', 'starlink',
75
+ 'stained-glass', 'barcode', 'inkblot', 'wave-stack', 'glitch-blocks', 'motion-area',
76
+ 'circuit-pulse', 'shard-field', 'honeycomb', 'magma', 'oil-slick', 'warp-streak',
77
+ 'blocks', 'vu-meters', 'dejong', 'truchet', 'termflix-fire', 'termflix-matrix',
78
+ 'termflix-plasma', 'termflix-starfield', 'termflix-waterfall', 'termflix-radar', 'wave',
79
+ 'life', 'particles', 'pendulum', 'rain', 'fountain', 'flow', 'spiral', 'ocean',
80
+ 'lightning', 'ripple', 'snow', 'garden', 'fireflies', 'dna', 'pulse', 'boids', 'lava',
81
+ 'sandstorm', 'petals', 'campfire', 'eclipse', 'blackhole', 'rainforest', 'crystallize',
82
+ 'hackerman', 'visualizer', 'cells', 'atom', 'automata', 'globe', 'dragon', 'sierpinski',
83
+ 'sierpinksi', 'mandelbrot', 'maze', 'nbody', 'langton', 'sort', 'tetris', 'snake',
84
+ 'invaders', 'pong', 'flappy-bird', 'reaction-diffusion', 'voronoi', 'orbits',
85
+ 'chromatic', 'ferro-crown', 'nautilus', 'vortex-street', 'kinetic-mobile',
86
+ 'harmonic-harp', 'neon-transit', 'lantern-drift', 'murmuration', 'koi-shoal',
87
+ 'calligraphy', 'aperture-bloom', 'kintsugi', 'sonic-loom', 'river-delta',
88
+ 'peacock-plume', 'eclipse-corona', 'cloud-chamber', 'origami-tide', 'tv-static',
89
+ 'sunspot', 'plasma', 'metaballs', 'moonlit-tide', 'motion-blob', 'clifford', 'paris',
90
+ 'kyoto', 'sahara'
91
+ ];
92
+ const retiredReceiverStyles = new Set(retiredReceiverStyleNames);
93
+ export function migrateReceiverStyle(value) {
94
+ if (value === 'equalizer') {
95
+ return 'ultracode';
96
+ }
97
+ if (value === 'sumi-mountains' || value === 'moonlit-tide') {
98
+ return 'sumi-ocean';
99
+ }
100
+ return typeof value === 'string' && retiredReceiverStyles.has(value) ? defaultReceiverStyle : value;
101
+ }