@ciphore/radiocli 0.2.2 → 0.2.4
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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
+
import { formatExploreCursor } from '../app-state.js';
|
|
4
5
|
import { StationList } from '../components/StationList.js';
|
|
5
6
|
import { buildCosmoWorldMap } from '../cosmo-world-map.js';
|
|
6
7
|
import { computeExploreMapLayout } from '../explore-map-layout.js';
|
|
@@ -14,7 +15,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, er
|
|
|
14
15
|
const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, mapOffsetX, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
|
|
15
16
|
const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
|
|
16
17
|
const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
|
|
17
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", "aria-label": `World map cursor: ${formatExploreCursor(cursor)}`, children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
|
|
18
19
|
? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…')
|
|
19
20
|
: error
|
|
20
21
|
? truncate(ascii ? toAsciiSafe(error) : error, Math.max(1, listPanelWidth - 2))
|
|
@@ -5,7 +5,11 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
|
5
5
|
import { commandHelp, keyHelpSections } from '../help-content.js';
|
|
6
6
|
import { padDisplayEnd, truncate } from '../format.js';
|
|
7
7
|
import { visibleWindow } from '../list-window.js';
|
|
8
|
+
import { useDisplay } from '../display-context.js';
|
|
9
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
8
10
|
export function HelpScreen({ theme, width, height, selected }) {
|
|
11
|
+
const { ascii } = useDisplay();
|
|
12
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
9
13
|
const accent = themeAccent(theme);
|
|
10
14
|
const keyColumnWidth = 16;
|
|
11
15
|
const lineWidth = Math.max(28, width - 2);
|
|
@@ -22,5 +26,5 @@ export function HelpScreen({ theme, width, height, selected }) {
|
|
|
22
26
|
}))
|
|
23
27
|
];
|
|
24
28
|
const window = visibleWindow(rows, selected, Math.max(3, height - 4));
|
|
25
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth) }), _jsx(Text, { color: textMuted, children: truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)) })] }, row.key))) }),
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)) }), _jsx(Text, { color: textMuted, children: a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))) })] }, row.key))) }), _jsx(Text, { color: textMuted, children: a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`) })] }));
|
|
26
30
|
}
|
|
@@ -4,6 +4,12 @@ import { Logo } from '../components/Logo.js';
|
|
|
4
4
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
5
5
|
import { textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { homeItems } from '../screen-items.js';
|
|
7
|
+
import { displayWidth } from '../format.js';
|
|
8
|
+
import { useDisplay } from '../display-context.js';
|
|
9
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
7
10
|
export function HomeScreen({ selected, theme, library }) {
|
|
8
|
-
|
|
11
|
+
const { ascii } = useDisplay();
|
|
12
|
+
const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`;
|
|
13
|
+
const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2;
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textMuted, children: ascii ? toAsciiSafe(summary) : summary }) })] }));
|
|
9
15
|
}
|
|
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
|
|
|
18
18
|
const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
|
|
19
19
|
const topWidth = mode === 'full' ? 50 : contentWidth;
|
|
20
20
|
const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", "aria-hidden": true, children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
|
|
22
22
|
}
|
|
23
23
|
function MapLine({ row, theme }) {
|
|
24
24
|
const { ascii } = useDisplay();
|
|
@@ -14,7 +14,7 @@ const receiverPaddingX = 3;
|
|
|
14
14
|
const receiverPaddingY = 1;
|
|
15
15
|
export function NowPlayingScreen({ station, playback, metadata, theme, favorite, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
|
|
16
16
|
const pulse = useReceiverPulse();
|
|
17
|
-
const { panel: panelBackground, ascii } = useDisplay();
|
|
17
|
+
const { panel: panelBackground, ascii, screenReader } = useDisplay();
|
|
18
18
|
// In ASCII mode route every rendered string through the glyph mapper so no
|
|
19
19
|
// braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
|
|
20
20
|
const a = (value) => (ascii ? toAsciiSafe(value) : value);
|
|
@@ -28,7 +28,7 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
28
28
|
// visualization equally separated from the header and track title.
|
|
29
29
|
const visualizerGutterRows = 1;
|
|
30
30
|
const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
|
|
31
|
-
const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
|
|
31
|
+
const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
|
|
32
32
|
const receiverState = playback.state.toUpperCase();
|
|
33
33
|
const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
|
|
34
34
|
const stationIdentity = receiverStationIdentity(station, identityWidth);
|
|
@@ -42,9 +42,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
42
42
|
: infoFallback;
|
|
43
43
|
const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
|
|
44
44
|
const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, children: receiverState })] }), _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
46
46
|
? renderSegments(row.segments)
|
|
47
|
-
: row.text }, index))) }), _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
47
|
+
: row.text }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
48
48
|
}
|
|
49
49
|
function asciifyVisualRow(row) {
|
|
50
50
|
return {
|
|
@@ -5,10 +5,12 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
|
5
5
|
import { panelBorder, textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
7
7
|
import { truncate } from '../format.js';
|
|
8
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
8
9
|
export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
|
|
9
10
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
11
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
10
12
|
const contentWidth = Math.max(40, width);
|
|
11
13
|
const inputWidth = Math.max(34, Math.min(contentWidth, 96));
|
|
12
14
|
const inputTextWidth = Math.max(8, inputWidth - 8);
|
|
13
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children:
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: a(editing ? '› ' : ' ') }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)) })] }), loading ? _jsx(Text, { color: textMuted, children: a('Searching station directories…') }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
|
|
14
16
|
}
|
|
@@ -1,73 +1,72 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { defaultAgentControlSettings } from '../../types.js';
|
|
3
4
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
4
5
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
6
|
import { truncate } from '../format.js';
|
|
6
|
-
import {
|
|
7
|
+
import { settingsGroup, settingsGroups, settingsItemsForPage } from '../screen-items.js';
|
|
7
8
|
import { textMuted, themeAccent } from '../theme.js';
|
|
8
9
|
import { playbackBackendCapabilities } from '../../player/backend-install.js';
|
|
9
10
|
import { airPlayReceiverSettingValue } from '../airplay-settings.js';
|
|
10
11
|
import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
|
|
11
12
|
import { updateStatusText } from '../../update-check.js';
|
|
12
|
-
|
|
13
|
+
import { useDisplay } from '../display-context.js';
|
|
14
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
15
|
+
export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height, airPlaySupported = true }) {
|
|
16
|
+
const { ascii } = useDisplay();
|
|
17
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
13
18
|
const accent = themeAccent(theme);
|
|
14
19
|
const lineWidth = Math.max(32, width - 4);
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
const group = settingsGroup(page);
|
|
21
|
+
const pageItems = settingsItemsForPage(page);
|
|
22
|
+
const menuWindow = settingsMenuWindow(pageItems, selected, height);
|
|
23
|
+
const selectedItem = pageItems[selected];
|
|
24
|
+
const detail = page === 'root' && selectedItem !== 'Check for updates'
|
|
25
|
+
? settingsGroupsDescription(selectedItem)
|
|
26
|
+
: settingDetail({
|
|
27
|
+
item: selectedItem,
|
|
28
|
+
appVersion,
|
|
29
|
+
updateCheck,
|
|
30
|
+
storePath,
|
|
31
|
+
playback,
|
|
32
|
+
providerHealth,
|
|
33
|
+
diagnostics,
|
|
34
|
+
lineWidth
|
|
35
|
+
});
|
|
28
36
|
const valueGap = 3;
|
|
29
37
|
const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
|
|
30
38
|
const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
|
|
31
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle:
|
|
32
|
-
const label = settingLabel(item
|
|
33
|
-
const value =
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: group ? `Settings › ${group.label}` : 'Choose a category', width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: item => item, render: (item, _index, active) => {
|
|
40
|
+
const label = settingLabel(item, updateCheck, appVersion, airPlaySupported);
|
|
41
|
+
const value = page === 'root'
|
|
42
|
+
? settingsRootValue(item)
|
|
43
|
+
: settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
|
|
44
|
+
return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(truncate(label, labelWidth)) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: a(truncate(value ?? '', valueWidth)) }) })] }));
|
|
45
|
+
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: a(truncate(detail, lineWidth)) })] })] }));
|
|
36
46
|
}
|
|
37
|
-
function settingsMenuWindow(selected, height) {
|
|
47
|
+
function settingsMenuWindow(items, selected, height) {
|
|
38
48
|
const reservedRows = 6;
|
|
39
49
|
const maxRows = height ? Math.max(5, height - reservedRows) : Number.POSITIVE_INFINITY;
|
|
40
|
-
const clampedSelected = Math.min(Math.max(selected, 0),
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
let previousSection = '';
|
|
45
|
-
settingsItems.forEach((item, index) => {
|
|
46
|
-
const section = settingsSectionFor(item);
|
|
47
|
-
let showSection = section !== previousSection;
|
|
48
|
-
let rowCost = 1 + (showSection ? 1 : 0);
|
|
49
|
-
if (page.length > 0 && pageRows + rowCost > maxRows) {
|
|
50
|
-
pages.push(page);
|
|
51
|
-
page = [];
|
|
52
|
-
pageRows = 0;
|
|
53
|
-
previousSection = '';
|
|
54
|
-
showSection = true;
|
|
55
|
-
rowCost = 2;
|
|
56
|
-
}
|
|
57
|
-
page.push({ item, section, showSection, index });
|
|
58
|
-
pageRows += rowCost;
|
|
59
|
-
previousSection = section;
|
|
60
|
-
});
|
|
61
|
-
if (page.length > 0) {
|
|
62
|
-
pages.push(page);
|
|
63
|
-
}
|
|
64
|
-
const selectedPage = pages.find(candidate => candidate.some(entry => entry.index === clampedSelected)) ?? pages[0] ?? [];
|
|
50
|
+
const clampedSelected = Math.min(Math.max(selected, 0), items.length - 1);
|
|
51
|
+
const pageSize = Number.isFinite(maxRows) ? Math.max(1, Math.floor(maxRows)) : items.length;
|
|
52
|
+
const pageStart = Math.floor(clampedSelected / pageSize) * pageSize;
|
|
53
|
+
const selectedPage = items.slice(pageStart, pageStart + pageSize);
|
|
65
54
|
return {
|
|
66
|
-
items: selectedPage,
|
|
67
|
-
selectedOffset:
|
|
55
|
+
items: [...selectedPage],
|
|
56
|
+
selectedOffset: clampedSelected - pageStart
|
|
68
57
|
};
|
|
69
58
|
}
|
|
70
|
-
|
|
59
|
+
function settingsRootValue(item) {
|
|
60
|
+
const group = settingsGroupByLabel(item);
|
|
61
|
+
return group ? `${group.items.length} settings ›` : undefined;
|
|
62
|
+
}
|
|
63
|
+
function settingsGroupByLabel(label) {
|
|
64
|
+
return settingsGroups.find(group => group.label === label);
|
|
65
|
+
}
|
|
66
|
+
function settingsGroupsDescription(label) {
|
|
67
|
+
return label ? settingsGroupByLabel(label)?.description ?? 'Press Enter to open this category.' : '';
|
|
68
|
+
}
|
|
69
|
+
export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion) {
|
|
71
70
|
switch (item) {
|
|
72
71
|
case 'Cycle display color':
|
|
73
72
|
return settings.theme;
|
|
@@ -100,21 +99,34 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
|
|
|
100
99
|
return settings.reduceMotion ? 'on' : 'off';
|
|
101
100
|
case 'Mouse and trackpad scrolling':
|
|
102
101
|
return settings.mouseSupport === false ? 'off' : 'auto';
|
|
102
|
+
case 'Automatically check for updates':
|
|
103
|
+
return settings.automaticUpdateChecks === false ? 'off' : 'on';
|
|
104
|
+
case 'Allow local agent control':
|
|
105
|
+
return (settings.agentControl ?? defaultAgentControlSettings).enabled ? 'on' : 'off';
|
|
106
|
+
case 'Install or repair MCP integrations':
|
|
107
|
+
return 'run repair';
|
|
108
|
+
case 'Open TUI for agent playback':
|
|
109
|
+
return (settings.agentControl ?? defaultAgentControlSettings).openUiOnPlay ? 'on' : 'off';
|
|
110
|
+
case 'Show Now Playing for agent playback':
|
|
111
|
+
return (settings.agentControl ?? defaultAgentControlSettings).focusNowPlaying ? 'on' : 'off';
|
|
103
112
|
case 'Check for updates':
|
|
104
|
-
return updateStatusText(updateCheck);
|
|
113
|
+
return updateStatusText(updateCheck, currentVersion);
|
|
105
114
|
case 'Reset learned media keys':
|
|
106
115
|
return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
|
|
107
116
|
case 'Export preferences and library':
|
|
108
117
|
return 'JSON backup';
|
|
109
|
-
case '
|
|
118
|
+
case 'Restore preferences and library':
|
|
110
119
|
return 'restore JSON backup';
|
|
111
120
|
default:
|
|
112
121
|
return undefined;
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
|
-
export function settingLabel(item, updateCheck) {
|
|
116
|
-
if (item === '
|
|
117
|
-
return '
|
|
124
|
+
export function settingLabel(item, updateCheck, currentVersion, airPlaySupported = true) {
|
|
125
|
+
if (item === 'AirPlay receiver' && !airPlaySupported) {
|
|
126
|
+
return 'AirPlay receiver (macOS only)';
|
|
127
|
+
}
|
|
128
|
+
if (item === 'Check for updates') {
|
|
129
|
+
return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
|
|
118
130
|
}
|
|
119
131
|
return settingDisplayLabels[item] ?? item;
|
|
120
132
|
}
|
|
@@ -135,9 +147,12 @@ function settingDetail(input) {
|
|
|
135
147
|
return health.length ? health.map(([provider, status]) => `${provider}: ${status}`).join(' · ') : 'Press Enter to check station providers.';
|
|
136
148
|
}
|
|
137
149
|
if (item === 'Check for updates') {
|
|
138
|
-
return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck)}`;
|
|
150
|
+
return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck, appVersion)}`;
|
|
151
|
+
}
|
|
152
|
+
if (item === 'Automatically check for updates') {
|
|
153
|
+
return 'When enabled, RadioCLI checks the registry once whenever the app launches.';
|
|
139
154
|
}
|
|
140
|
-
if (item === 'Export preferences and library' || item === '
|
|
155
|
+
if (item === 'Export preferences and library' || item === 'Restore preferences and library') {
|
|
141
156
|
return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
|
|
142
157
|
}
|
|
143
158
|
if (item === 'Share favorite votes with Radio Browser') {
|
|
@@ -149,5 +164,14 @@ function settingDetail(input) {
|
|
|
149
164
|
if (item === 'Reduce motion') {
|
|
150
165
|
return 'Freezes animated receiver displays for calmer, lower-power rendering.';
|
|
151
166
|
}
|
|
167
|
+
if (item === 'Allow local agent control') {
|
|
168
|
+
return 'Press Enter to set up or remove agent and Codex Voice control across detected clients.';
|
|
169
|
+
}
|
|
170
|
+
if (item === 'Install or repair MCP integrations') {
|
|
171
|
+
return 'Checks and repairs every detected client. Restart open Codex or agent clients afterward.';
|
|
172
|
+
}
|
|
173
|
+
if (item === 'Open TUI for agent playback') {
|
|
174
|
+
return 'Opt in to a separate terminal window. On macOS, the agent app must approve a one-time Automation prompt.';
|
|
175
|
+
}
|
|
152
176
|
return 'Press Enter to change this setting.';
|
|
153
177
|
}
|
|
@@ -6,6 +6,7 @@ import { panelBorder, textMuted, themeAccent, themeContributionColors } from '..
|
|
|
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
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
9
10
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
10
11
|
const dayLabelWidth = 5;
|
|
11
12
|
// Two terminal columns are approximately one row high in common monospace
|
|
@@ -15,6 +16,7 @@ const heatmapCell = ' ';
|
|
|
15
16
|
const heatmapGap = '';
|
|
16
17
|
export function StatsScreen({ library, theme, width, height }) {
|
|
17
18
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
19
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
18
20
|
const stats = computeListeningStats(library.activity.sessions);
|
|
19
21
|
const contentWidth = Math.max(20, width - 4);
|
|
20
22
|
const graph = buildContributionGraph(stats.days, contentWidth, height);
|
|
@@ -24,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
24
26
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
25
27
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
26
28
|
const compact = height < 30;
|
|
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:
|
|
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 }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: a(`Activity — ${graph.year}`) }) }), _jsxs(Box, { flexDirection: "column", "aria-label": `Activity graph: ${stats.activeDays} active days in ${graph.year}`, 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', a(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, "aria-hidden": true, children: [_jsx(Text, { color: textMuted, children: a('Less · ') }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
|
|
28
30
|
}
|
|
29
31
|
function renderContributionCells(cells, graphColors, cellGap) {
|
|
30
32
|
const runs = [];
|
|
@@ -1,65 +1,93 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { resolveCommand } from '../
|
|
2
|
+
import { resolveCommand } from '../platform/executables.js';
|
|
3
3
|
import { safeExternalHttpUrl } from '../safety.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
if (platform === 'win32') {
|
|
10
|
-
// Direct invocation avoids cmd.exe interpreting URL query characters.
|
|
11
|
-
return { command: 'explorer', args: [] };
|
|
12
|
-
}
|
|
13
|
-
return { command: 'xdg-open', args: [] };
|
|
14
|
-
}
|
|
15
|
-
// Candidate clipboard tools per platform, in priority order. pbcopy/clip ship
|
|
16
|
-
// with macOS/Windows; Linux/BSD rely on whichever of these is installed.
|
|
17
|
-
export function clipboardCommands(platform = process.platform) {
|
|
18
|
-
if (platform === 'darwin') {
|
|
19
|
-
return [{ command: 'pbcopy', args: [] }];
|
|
20
|
-
}
|
|
21
|
-
if (platform === 'win32') {
|
|
22
|
-
return [{ command: 'clip', args: [] }];
|
|
23
|
-
}
|
|
24
|
-
return [
|
|
25
|
-
{ command: 'wl-copy', args: [] },
|
|
26
|
-
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
27
|
-
{ command: 'xsel', args: ['--clipboard', '--input'] }
|
|
28
|
-
];
|
|
29
|
-
}
|
|
30
|
-
export function openExternal(url, platform = process.platform) {
|
|
4
|
+
import { browserCommands, clipboardCandidates, hasGraphicalSession } from '../platform/desktop.js';
|
|
5
|
+
import { identifyPlatform } from '../platform/runtime.js';
|
|
6
|
+
import { waitForLaunch } from '../platform/launch-command.js';
|
|
7
|
+
export async function openExternal(url, platform = process.platform, deps = {}) {
|
|
31
8
|
const safeUrl = safeExternalHttpUrl(url);
|
|
32
|
-
|
|
9
|
+
const env = deps.env ?? process.env;
|
|
10
|
+
const host = identifyPlatform({ platform, env });
|
|
11
|
+
if (!safeUrl || !hasGraphicalSession(host, env))
|
|
33
12
|
return false;
|
|
13
|
+
for (const { command, args } of browserCommands(host)) {
|
|
14
|
+
const executable = (deps.resolve ?? resolveCommand)(command);
|
|
15
|
+
if (!executable)
|
|
16
|
+
continue;
|
|
17
|
+
try {
|
|
18
|
+
// xdg-open may run for the browser's entire lifetime. Observe startup
|
|
19
|
+
// failure, then let the desktop application own its detached process.
|
|
20
|
+
const child = (deps.spawn ?? spawn)(executable, [...args, safeUrl], { stdio: 'ignore', env, detached: true, windowsHide: true });
|
|
21
|
+
await waitForLaunch(child);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch { /* Try the next available opener after a launch failure. */ }
|
|
34
25
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
// Opening a browser is best-effort; never crash the TUI over it.
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
export async function copyToClipboard(text, platform = process.platform, deps = {}) {
|
|
29
|
+
const env = deps.env ?? process.env;
|
|
30
|
+
const host = identifyPlatform({ platform, env });
|
|
31
|
+
if (!hasGraphicalSession(host, env))
|
|
44
32
|
return false;
|
|
33
|
+
for (const { command, args } of clipboardCandidates(host)) {
|
|
34
|
+
const executable = (deps.resolve ?? resolveCommand)(command);
|
|
35
|
+
if (executable && await runHelper(executable, args, text, deps))
|
|
36
|
+
return true;
|
|
45
37
|
}
|
|
38
|
+
return false;
|
|
46
39
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
function runHelper(command, args, input, deps) {
|
|
41
|
+
return new Promise(resolve => {
|
|
42
|
+
let child;
|
|
43
|
+
let settled = false;
|
|
44
|
+
let stopping = false;
|
|
45
|
+
let forceTimer;
|
|
46
|
+
let cleanupTimer;
|
|
47
|
+
const signal = (value) => {
|
|
48
|
+
try {
|
|
49
|
+
child?.kill(value);
|
|
50
|
+
}
|
|
51
|
+
catch { /* The failed action remains false; cleanup is still bounded. */ }
|
|
52
|
+
};
|
|
53
|
+
const finish = (ok) => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
clearTimeout(forceTimer);
|
|
59
|
+
clearTimeout(cleanupTimer);
|
|
60
|
+
resolve(ok);
|
|
61
|
+
};
|
|
62
|
+
const stop = () => {
|
|
63
|
+
if (settled || stopping)
|
|
64
|
+
return;
|
|
65
|
+
stopping = true;
|
|
66
|
+
forceTimer = setTimeout(() => signal('SIGKILL'), 250);
|
|
67
|
+
cleanupTimer = setTimeout(() => {
|
|
68
|
+
// Even an uninterruptible OS process must not keep RadioCLI alive.
|
|
69
|
+
child?.unref();
|
|
70
|
+
finish(false);
|
|
71
|
+
}, 1_000);
|
|
72
|
+
child?.stdin?.destroy();
|
|
73
|
+
signal('SIGTERM');
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(stop, deps.timeoutMs ?? 5000);
|
|
53
76
|
try {
|
|
54
|
-
|
|
55
|
-
child.on('error', () =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
child = (deps.spawn ?? spawn)(command, args, { stdio: ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true });
|
|
78
|
+
child.on('error', () => { if (child?.pid)
|
|
79
|
+
stop();
|
|
80
|
+
else
|
|
81
|
+
finish(false); });
|
|
82
|
+
child.once('close', code => finish(!stopping && code === 0));
|
|
83
|
+
child.stdin?.on('error', stop);
|
|
84
|
+
child.stdin?.end(input);
|
|
59
85
|
}
|
|
60
86
|
catch {
|
|
61
|
-
|
|
87
|
+
if (child?.pid)
|
|
88
|
+
stop();
|
|
89
|
+
else
|
|
90
|
+
finish(false);
|
|
62
91
|
}
|
|
63
|
-
}
|
|
64
|
-
return false;
|
|
92
|
+
});
|
|
65
93
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { resolveTerminalCapabilities } from '../platform/terminal.js';
|
|
4
|
+
/**
|
|
5
|
+
* Ink uses Chalk's shared instance for both text and panel fills. FORCE_COLOR
|
|
6
|
+
* is a minimum in Chalk, so setting it cannot cap a Windows/CI/truecolor host
|
|
7
|
+
* at 16 or 256 colors. Set the instance's level before the first Ink render.
|
|
8
|
+
* Resolve from Ink itself so nested dependency installations work as well.
|
|
9
|
+
*/
|
|
10
|
+
export async function configureTerminalRenderer(env = process.env, evidence = {}) {
|
|
11
|
+
const terminal = resolveTerminalCapabilities(env, evidence);
|
|
12
|
+
const inkRequire = createRequire(import.meta.resolve('ink'));
|
|
13
|
+
const { default: chalk } = await import(pathToFileURL(inkRequire.resolve('chalk')).href);
|
|
14
|
+
chalk.level = terminal.colorLevel;
|
|
15
|
+
return terminal;
|
|
16
|
+
}
|
package/dist/ui/use-alarm-tui.js
CHANGED
|
@@ -180,6 +180,40 @@ export function useAlarmTui(params) {
|
|
|
180
180
|
}
|
|
181
181
|
await refreshRuntime();
|
|
182
182
|
}, [refreshRuntime, service, setMessage]);
|
|
183
|
+
const deleteAlarm = useCallback(async (alarm) => {
|
|
184
|
+
let phase = 'disable';
|
|
185
|
+
let message;
|
|
186
|
+
try {
|
|
187
|
+
// Persist the safe state before touching native jobs. A failed cleanup
|
|
188
|
+
// can then be retried without another write in the error handler.
|
|
189
|
+
const disabled = store.toggleAlarm(alarm.id, false);
|
|
190
|
+
const disabledSnapshot = store.snapshot();
|
|
191
|
+
alarmStateRef.current = disabledSnapshot.alarms;
|
|
192
|
+
setLibrary(disabledSnapshot);
|
|
193
|
+
phase = 'native';
|
|
194
|
+
await service.remove(disabled);
|
|
195
|
+
phase = 'definition';
|
|
196
|
+
store.removeAlarm(alarm.id);
|
|
197
|
+
const snapshot = store.snapshot();
|
|
198
|
+
alarmStateRef.current = snapshot.alarms;
|
|
199
|
+
setLibrary(snapshot);
|
|
200
|
+
setSelected(value => clamp(value, snapshot.alarms.length + 1));
|
|
201
|
+
message = `Deleted ${alarm.label}.`;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
message = phase === 'disable'
|
|
205
|
+
? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.`
|
|
206
|
+
: phase === 'native'
|
|
207
|
+
? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.`
|
|
208
|
+
: `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
busyAlarmIdsRef.current.delete(alarm.id);
|
|
212
|
+
setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
|
|
213
|
+
}
|
|
214
|
+
await refreshRuntime();
|
|
215
|
+
setMessage(message);
|
|
216
|
+
}, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]);
|
|
183
217
|
const saveDraft = useCallback(() => {
|
|
184
218
|
if (!draft || savingRef.current)
|
|
185
219
|
return;
|
|
@@ -343,26 +377,7 @@ export function useAlarmTui(params) {
|
|
|
343
377
|
setDeletingId(undefined);
|
|
344
378
|
busyAlarmIdsRef.current.add(alarm.id);
|
|
345
379
|
setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
|
|
346
|
-
void
|
|
347
|
-
if (store.getAlarm(alarm.id))
|
|
348
|
-
store.removeAlarm(alarm.id);
|
|
349
|
-
const snapshot = store.snapshot();
|
|
350
|
-
alarmStateRef.current = snapshot.alarms;
|
|
351
|
-
setLibrary(snapshot);
|
|
352
|
-
setSelected(value => clamp(value, snapshot.alarms.length + 1));
|
|
353
|
-
setMessage(`Deleted ${alarm.label}.`);
|
|
354
|
-
}).catch(error => {
|
|
355
|
-
const existing = store.getAlarm(alarm.id);
|
|
356
|
-
if (existing) {
|
|
357
|
-
const disabled = store.toggleAlarm(alarm.id, false);
|
|
358
|
-
const snapshot = store.snapshot();
|
|
359
|
-
alarmStateRef.current = snapshot.alarms;
|
|
360
|
-
setLibrary(snapshot);
|
|
361
|
-
setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`);
|
|
362
|
-
}
|
|
363
|
-
else
|
|
364
|
-
setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`);
|
|
365
|
-
}).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); });
|
|
380
|
+
void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`));
|
|
366
381
|
return true;
|
|
367
382
|
}
|
|
368
383
|
if (input !== 'x') {
|
|
@@ -461,6 +476,10 @@ export function useAlarmTui(params) {
|
|
|
461
476
|
if (!active)
|
|
462
477
|
return false;
|
|
463
478
|
if (key.return) {
|
|
479
|
+
if (active.status.state === 'starting') {
|
|
480
|
+
setMessage(`Still preparing ${active.status.stationName}; handoff will be available once playback starts.`);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
464
483
|
if (pendingActiveActionsRef.current.has(active.key)) {
|
|
465
484
|
setMessage(`An alarm control request is already pending for ${active.status.stationName}.`);
|
|
466
485
|
return true;
|
|
@@ -673,7 +692,7 @@ export function useAlarmTui(params) {
|
|
|
673
692
|
return true;
|
|
674
693
|
}
|
|
675
694
|
return false;
|
|
676
|
-
}, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
|
|
695
|
+
}, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
|
|
677
696
|
const openActive = useCallback(() => {
|
|
678
697
|
if (!activeAlarms.length) {
|
|
679
698
|
setMessage('No alarm is currently playing.');
|