@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.
- package/CHANGELOG.md +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +109 -10
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- 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,
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
|
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,
|
|
68
|
+
export function buildContributionGraph(days, width, _height = 26) {
|
|
73
69
|
const year = graphYear(days);
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
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
|
|
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:
|
|
95
|
+
return { key: date, level, text: cellText, visible: day.visible };
|
|
103
96
|
})
|
|
104
97
|
}));
|
|
105
|
-
return { year, months: monthLine(weeks, cellWidth
|
|
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
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const gridStart = addLocalDays(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
143
|
+
function monthLine(weeks, cellWidth) {
|
|
153
144
|
const cells = Array.from({ length: weeks.length * cellWidth }, () => ' ');
|
|
154
|
-
for (let
|
|
155
|
-
const
|
|
156
|
-
const firstOfMonth =
|
|
157
|
-
|
|
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
|
-
//
|
|
10
|
-
return { command: '
|
|
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,
|
|
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';
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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' &&
|
|
293
|
-
const menuIndex =
|
|
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
|
+
}
|