@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.
- package/CHANGELOG.md +44 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +36 -2
- 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 +169 -103
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +26 -6
- package/dist/providers/radio-browser.js +139 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +253 -152
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +251 -47
- package/dist/ui/AppContent.js +9 -10
- 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 +9 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +12 -1
- package/dist/ui/screen-items.js +60 -22
- 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 +93 -21
- 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 +29 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +24 -7
- package/dist/ui/use-command-executor.js +28 -0
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- 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,
|
|
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,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';
|
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, 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 =
|
|
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
|
-
|
|
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' &&
|
|
298
|
-
const menuIndex =
|
|
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 [
|
|
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
|
+
}
|