@ciphore/radiocli 0.2.0 → 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 (57) hide show
  1. package/CHANGELOG.md +44 -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 +36 -2
  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 +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  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 +9 -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 +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  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 +93 -21
  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 +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
@@ -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,6 +11,35 @@ 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
  }
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, wheelScrollDelta } 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, updateFromSettings }) {
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,7 +20,7 @@ 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
25
  const wheelDelta = wheelScrollDelta(mouseEvents);
26
26
  const click = primaryMousePress(mouseEvents);
@@ -75,7 +75,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
75
75
  ]);
76
76
  useInput((input, key) => {
77
77
  if (key.ctrl && input === 'c') {
78
- shutdown();
78
+ confirmCtrlCExit();
79
79
  return;
80
80
  }
81
81
  if (!shouldHandleKeyboardEvent(key.eventType)) {
@@ -294,8 +294,8 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
294
294
  setSelected(value => clamp(value - 10, currentItemCount(screen) - 1));
295
295
  return;
296
296
  }
297
- if (screen === 'home' && (/^[1-9]$/.test(input) || input === '0')) {
298
- const menuIndex = input === '0' ? 9 : Number(input) - 1;
297
+ if (screen === 'home' && /^[1-8]$/.test(input)) {
298
+ const menuIndex = Number(input) - 1;
299
299
  setSelected(menuIndex);
300
300
  const target = homeItems[menuIndex]?.screen;
301
301
  if (target) {
@@ -401,6 +401,9 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
401
401
  else if (item === 'Toggle nearby location lookup') {
402
402
  toggleNearbyLocation();
403
403
  }
404
+ else if (item === 'Share favorite votes with Radio Browser') {
405
+ toggleDirectoryVoting();
406
+ }
404
407
  else if (item === 'Audio output') {
405
408
  cycleAudioOutput();
406
409
  }
@@ -431,6 +434,17 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
431
434
  else if (item === 'Reduce motion') {
432
435
  toggleSetting('reduceMotion');
433
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
+ }
434
448
  else if (item === 'Check for updates') {
435
449
  void updateFromSettings();
436
450
  }
@@ -473,7 +487,10 @@ function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilte
473
487
  if (commandMode || editingCountryFilter) {
474
488
  return false;
475
489
  }
476
- return ['countries', 'map', 'stations', 'search', 'nearby', 'explore', 'library'].includes(screen);
490
+ return [
491
+ 'home', 'countries', 'map', 'stations', 'search', 'nearby', 'explore',
492
+ 'library', 'settings', 'help', 'airplay-settings'
493
+ ].includes(screen);
477
494
  }
478
495
  function exploreMoveForInput(input) {
479
496
  const normalized = input.toLowerCase();
@@ -136,6 +136,34 @@ 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
+ }
139
167
  if (name === 'update') {
140
168
  await updateCommand();
141
169
  return;
@@ -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
+ }