@ciphore/radiocli 0.1.5 → 0.1.6
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 +27 -0
- package/README.md +6 -4
- package/dist/cli.js +6 -0
- package/dist/player/backend-install.js +23 -0
- package/dist/player/command.js +92 -4
- package/dist/player/player-controller.js +26 -4
- package/dist/providers/provider-manager.js +3 -0
- package/dist/providers/radio-browser.js +18 -2
- package/dist/storage/store.js +50 -2
- package/dist/ui/App.js +115 -13
- package/dist/ui/AppContent.js +5 -1
- package/dist/ui/app-state.js +1 -1
- package/dist/ui/ascii.js +84 -0
- package/dist/ui/audio-output.js +4 -1
- package/dist/ui/components/ScreenHeader.js +6 -2
- package/dist/ui/components/TopTabs.js +8 -3
- package/dist/ui/display-context.js +25 -0
- package/dist/ui/help-content.js +116 -0
- package/dist/ui/page-footer.js +4 -1
- package/dist/ui/screen-items.js +4 -0
- package/dist/ui/screens/ExploreScreen.js +7 -3
- package/dist/ui/screens/HelpScreen.js +12 -0
- package/dist/ui/screens/MapScreen.js +7 -2
- package/dist/ui/screens/NowPlayingScreen.js +27 -4
- package/dist/ui/screens/SearchScreen.js +4 -2
- package/dist/ui/screens/SettingsScreen.js +8 -0
- package/dist/ui/screens/StatsScreen.js +4 -2
- package/dist/ui/screens/screen-render.test.js +114 -0
- package/dist/ui/system-actions.js +58 -0
- package/dist/ui/use-app-input.js +39 -1
- package/dist/ui/use-command-executor.js +22 -0
- package/dist/version.js +20 -0
- package/package.json +2 -1
package/dist/ui/screen-items.js
CHANGED
|
@@ -19,6 +19,10 @@ export const settingsItems = [
|
|
|
19
19
|
'Volume down',
|
|
20
20
|
'Mute or unmute',
|
|
21
21
|
'Toggle skip broken streams',
|
|
22
|
+
'Resume last station on launch',
|
|
23
|
+
'Transparent background',
|
|
24
|
+
'ASCII-safe display',
|
|
25
|
+
'Reduce motion',
|
|
22
26
|
'Refresh provider health',
|
|
23
27
|
'Learn previous media key',
|
|
24
28
|
'Learn play/pause media key',
|
|
@@ -5,14 +5,18 @@ import { StationList } from '../components/StationList.js';
|
|
|
5
5
|
import { buildCosmoWorldMap } from '../cosmo-world-map.js';
|
|
6
6
|
import { computeExploreMapLayout } from '../explore-map-layout.js';
|
|
7
7
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
8
|
-
import { exploreMapLand, mapMarker,
|
|
8
|
+
import { exploreMapLand, mapMarker, panelBorder, themeAccent } from '../theme.js';
|
|
9
|
+
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
10
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
9
11
|
export function ExploreScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
|
|
12
|
+
const { panel: panelBackground, ascii } = useDisplay();
|
|
10
13
|
const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
|
|
11
14
|
const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
|
|
12
15
|
const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
|
|
13
|
-
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:
|
|
16
|
+
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: 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: "gray", children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
|
|
14
17
|
}
|
|
15
18
|
function CosmoMapLine({ row, theme }) {
|
|
19
|
+
const { ascii } = useDisplay();
|
|
16
20
|
const chunks = [];
|
|
17
21
|
for (const cell of row.cells) {
|
|
18
22
|
const previous = chunks.at(-1);
|
|
@@ -27,7 +31,7 @@ function CosmoMapLine({ row, theme }) {
|
|
|
27
31
|
return (_jsx(Box, { children: chunks.map(chunk => {
|
|
28
32
|
const key = `${offset}-${chunk.kind}`;
|
|
29
33
|
offset += chunk.text.length;
|
|
30
|
-
return (_jsx(Text, { color: cosmoMapColor(chunk.kind, theme), children: chunk.text }, key));
|
|
34
|
+
return (_jsx(Text, { color: cosmoMapColor(chunk.kind, theme), children: ascii ? toAsciiSafe(chunk.text) : chunk.text }, key));
|
|
31
35
|
}) }));
|
|
32
36
|
}
|
|
33
37
|
function cosmoMapColor(kind, theme) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { themeAccent } from '../theme.js';
|
|
4
|
+
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
|
+
import { commandHelp, keyHelpSections } from '../help-content.js';
|
|
6
|
+
import { truncate } from '../format.js';
|
|
7
|
+
export function HelpScreen({ theme, width }) {
|
|
8
|
+
const accent = themeAccent(theme);
|
|
9
|
+
const keyColumnWidth = 16;
|
|
10
|
+
const lineWidth = Math.max(28, width - 2);
|
|
11
|
+
return (_jsxs(Box, { flexDirection: "column", 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: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: "gray", children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: "gray", children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
|
|
12
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { mapLand, mapWater, themeAccent, themeContributionColors } from '../theme.js';
|
|
4
|
+
import { useDisplay } from '../display-context.js';
|
|
5
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
4
6
|
import { visibleWindow } from '../list-window.js';
|
|
5
7
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
6
8
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
7
9
|
import { truncate } from '../format.js';
|
|
8
10
|
import { buildWorldMap } from '../world-map.js';
|
|
9
11
|
export function MapScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, mode, width }) {
|
|
12
|
+
const { ascii } = useDisplay();
|
|
13
|
+
const asciify = (value) => (ascii ? toAsciiSafe(value) : value);
|
|
10
14
|
const contentWidth = Math.max(52, width - 2);
|
|
11
15
|
const topCountries = Array.from(countries).sort((a, b) => b.stationCount - a.stationCount).slice(0, mode === 'full' ? 10 : 6);
|
|
12
16
|
const selectedCountry = countries[selected];
|
|
@@ -14,9 +18,10 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
|
|
|
14
18
|
const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
|
|
15
19
|
const topWidth = mode === 'full' ? 50 : contentWidth;
|
|
16
20
|
const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
|
|
17
|
-
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 || 'all'}` }), loading ? _jsx(Text, { color: "gray", children:
|
|
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 || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: "gray", 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: "gray", children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: "gray", children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _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: "gray", children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
|
|
18
22
|
}
|
|
19
23
|
function MapLine({ row, theme }) {
|
|
24
|
+
const { ascii } = useDisplay();
|
|
20
25
|
const chunks = [];
|
|
21
26
|
for (const cell of row.cells) {
|
|
22
27
|
const previous = chunks[chunks.length - 1];
|
|
@@ -33,7 +38,7 @@ function MapLine({ row, theme }) {
|
|
|
33
38
|
keyedChunks.push({ key: `${offset}-${chunk.kind}`, ...chunk });
|
|
34
39
|
offset += chunk.text.length;
|
|
35
40
|
}
|
|
36
|
-
return (_jsx(Box, { children: keyedChunks.map(chunk => (_jsx(Text, { color: mapColor(chunk.kind, theme), children: chunk.text }, chunk.key))) }));
|
|
41
|
+
return (_jsx(Box, { children: keyedChunks.map(chunk => (_jsx(Text, { color: mapColor(chunk.kind, theme), children: ascii ? toAsciiSafe(chunk.text) : chunk.text }, chunk.key))) }));
|
|
37
42
|
}
|
|
38
43
|
function mapColor(kind, theme) {
|
|
39
44
|
const colors = themeContributionColors(theme);
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
4
|
-
import {
|
|
4
|
+
import { themeAccent } from '../theme.js';
|
|
5
|
+
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
6
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
5
7
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
6
8
|
import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
|
|
7
|
-
export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, sleepLabel, showDiagnostics, stationTime, receiverStyle, width, height }) {
|
|
9
|
+
export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, sleepLabel, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
|
|
10
|
+
const { panel: panelBackground, ascii } = useDisplay();
|
|
11
|
+
// In ASCII mode route every rendered string through the glyph mapper so no
|
|
12
|
+
// braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
|
|
13
|
+
const a = (value) => (ascii ? toAsciiSafe(value) : value);
|
|
14
|
+
const stationTracks = recentTracksForStation(trackHistory, station, 3);
|
|
8
15
|
const accent = themeAccent(theme);
|
|
9
16
|
const panelWidth = Math.max(62, width);
|
|
10
17
|
const panelHeight = Math.max(10, height);
|
|
@@ -27,9 +34,25 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
27
34
|
? truncate(metadata.title, Math.max(8, innerWidth - 12))
|
|
28
35
|
: 'Waiting for ICY track metadata';
|
|
29
36
|
const dialLabel = receiverDialLabel(station);
|
|
30
|
-
|
|
37
|
+
const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
|
|
38
|
+
const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
|
|
39
|
+
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: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: "gray", children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
31
40
|
? renderSegments(row.segments)
|
|
32
|
-
: row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: metadataLine }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children:
|
|
41
|
+
: row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children: a(favoriteText) })] }), _jsx(Text, { color: "gray", children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Diagnostics" }), _jsx(Text, { color: "gray", children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: "gray", children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: "gray", 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: "gray", children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: "gray", children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
42
|
+
}
|
|
43
|
+
function asciifyVisualRow(row) {
|
|
44
|
+
return {
|
|
45
|
+
...row,
|
|
46
|
+
text: toAsciiSafe(row.text),
|
|
47
|
+
segments: row.segments?.map(segment => ({ ...segment, text: toAsciiSafe(segment.text) }))
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function recentTracksForStation(history, station, limit) {
|
|
51
|
+
if (!station) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const key = `${station.provider}:${station.id}`;
|
|
55
|
+
return history.filter(track => track.stationKey === key).slice(0, limit);
|
|
33
56
|
}
|
|
34
57
|
export function receiverDialLabel(station) {
|
|
35
58
|
if (!station) {
|
|
@@ -2,11 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { StationList } from '../components/StationList.js';
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
|
-
import {
|
|
5
|
+
import { panelBorder, themeAccent } from '../theme.js';
|
|
6
|
+
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
6
7
|
import { truncate } from '../format.js';
|
|
7
8
|
export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
|
|
9
|
+
const { panel: panelBackground, ascii } = useDisplay();
|
|
8
10
|
const contentWidth = Math.max(40, width);
|
|
9
11
|
const inputWidth = Math.max(34, Math.min(contentWidth, 96));
|
|
10
12
|
const inputTextWidth = Math.max(8, inputWidth - 8);
|
|
11
|
-
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:
|
|
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) : 'gray', children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : 'gray', children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: "gray", children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
|
|
12
14
|
}
|
|
@@ -38,6 +38,14 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
|
|
|
38
38
|
return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
|
|
39
39
|
case 'Toggle skip broken streams':
|
|
40
40
|
return settings.skipBrokenStreams ? 'on' : 'off';
|
|
41
|
+
case 'Resume last station on launch':
|
|
42
|
+
return settings.resumeOnLaunch ? 'on' : 'off';
|
|
43
|
+
case 'Transparent background':
|
|
44
|
+
return settings.transparentBackground ? 'on' : 'off';
|
|
45
|
+
case 'ASCII-safe display':
|
|
46
|
+
return settings.asciiMode ? 'on' : 'off';
|
|
47
|
+
case 'Reduce motion':
|
|
48
|
+
return settings.reduceMotion ? 'on' : 'off';
|
|
41
49
|
case 'Reset learned media keys':
|
|
42
50
|
return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
|
|
43
51
|
default:
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { computeListeningStats } from '../../activity/stats.js';
|
|
4
|
-
import {
|
|
4
|
+
import { panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
|
|
5
|
+
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
5
6
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
6
7
|
import { truncate } from '../format.js';
|
|
7
8
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
8
9
|
export function StatsScreen({ library, theme, width, height }) {
|
|
10
|
+
const { panel: panelBackground, ascii } = useDisplay();
|
|
9
11
|
const stats = computeListeningStats(library.activity.sessions);
|
|
10
12
|
const contentWidth = Math.max(20, width - 4);
|
|
11
13
|
const graph = buildContributionGraph(stats.days, contentWidth);
|
|
@@ -15,7 +17,7 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
15
17
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
16
18
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
17
19
|
const compact = height < 30;
|
|
18
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle:
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: ["\u2588\u2588", _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
|
|
19
21
|
? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
|
|
20
22
|
: 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
|
|
21
23
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { DisplayContext, resolveDisplayMode } from '../display-context.js';
|
|
5
|
+
import { HelpScreen } from './HelpScreen.js';
|
|
6
|
+
import { NowPlayingScreen } from './NowPlayingScreen.js';
|
|
7
|
+
import { SettingsScreen } from './SettingsScreen.js';
|
|
8
|
+
import { ExploreScreen } from './ExploreScreen.js';
|
|
9
|
+
import { settingsItems } from '../screen-items.js';
|
|
10
|
+
import { defaultExploreCursor } from '../app-state.js';
|
|
11
|
+
const station = {
|
|
12
|
+
id: 'station-1',
|
|
13
|
+
provider: 'radio-browser',
|
|
14
|
+
name: 'KEXP 90.3 FM',
|
|
15
|
+
country: 'United States',
|
|
16
|
+
tags: ['indie'],
|
|
17
|
+
codec: 'MP3',
|
|
18
|
+
bitrate: 128
|
|
19
|
+
};
|
|
20
|
+
const playback = {
|
|
21
|
+
backend: 'mpv',
|
|
22
|
+
state: 'idle',
|
|
23
|
+
volume: 70,
|
|
24
|
+
muted: false,
|
|
25
|
+
ready: false
|
|
26
|
+
};
|
|
27
|
+
const diagnostics = {
|
|
28
|
+
backend: 'mpv',
|
|
29
|
+
availableBackends: ['mpv'],
|
|
30
|
+
preferredBackend: 'auto',
|
|
31
|
+
active: false,
|
|
32
|
+
volume: 70,
|
|
33
|
+
muted: false,
|
|
34
|
+
ready: false
|
|
35
|
+
};
|
|
36
|
+
const trackHistory = [
|
|
37
|
+
{ title: 'Bjork - Joga', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '2' },
|
|
38
|
+
{ title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '1' }
|
|
39
|
+
];
|
|
40
|
+
function renderNowPlaying(asciiMode, showDiagnostics) {
|
|
41
|
+
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
42
|
+
return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(NowPlayingScreen, { station: station, playback: playback, metadata: null, theme: "green", favorite: true, pulse: 0, diagnostics: diagnostics, sleepLabel: "Sleep off", showDiagnostics: showDiagnostics, stationTime: "12:00", receiverStyle: "pulse-grid", trackHistory: trackHistory, width: 72, height: 30 }) }));
|
|
43
|
+
}
|
|
44
|
+
const settings = {
|
|
45
|
+
theme: 'green',
|
|
46
|
+
receiverStyle: 'pulse-grid',
|
|
47
|
+
volume: 70,
|
|
48
|
+
enableRadioGarden: false,
|
|
49
|
+
enableNearbyLocation: false,
|
|
50
|
+
preferredBackend: 'auto',
|
|
51
|
+
tuneTimeoutSeconds: 12,
|
|
52
|
+
skipBrokenStreams: true,
|
|
53
|
+
mediaKeys: { previous: [], playPause: [], next: [] },
|
|
54
|
+
resumeOnLaunch: true,
|
|
55
|
+
asciiMode: true,
|
|
56
|
+
reduceMotion: false,
|
|
57
|
+
transparentBackground: false
|
|
58
|
+
};
|
|
59
|
+
describe('SettingsScreen rendering', () => {
|
|
60
|
+
it('renders the new display and playback toggles with their values', () => {
|
|
61
|
+
const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
|
|
62
|
+
const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
|
|
63
|
+
const frame = lastFrame() ?? '';
|
|
64
|
+
expect(frame).toContain('Resume last station on launch');
|
|
65
|
+
expect(frame).toContain('ASCII-safe display');
|
|
66
|
+
expect(frame).toContain('Reduce motion');
|
|
67
|
+
expect(frame).toContain('Transparent background');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
function renderExplore(asciiMode) {
|
|
71
|
+
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
72
|
+
return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(ExploreScreen, { title: "Explore", subtitle: "Move a map cursor through geotagged stations", stations: [station], selected: 0, loading: false, theme: "green", favorites: new Set(), filterLabel: "", cursor: defaultExploreCursor, pageSize: 8, width: 100, height: 24 }) }));
|
|
73
|
+
}
|
|
74
|
+
describe('Explore world map rendering', () => {
|
|
75
|
+
it('rasterizes land with braille glyphs by default', () => {
|
|
76
|
+
const frame = renderExplore(false).lastFrame() ?? '';
|
|
77
|
+
expect(/[⠀-⣿]/.test(frame)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('replaces braille with ASCII in ASCII-safe mode', () => {
|
|
80
|
+
const frame = renderExplore(true).lastFrame() ?? '';
|
|
81
|
+
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('HelpScreen rendering', () => {
|
|
85
|
+
it('lists keybinding sections and : commands', () => {
|
|
86
|
+
const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
|
|
87
|
+
const frame = lastFrame() ?? '';
|
|
88
|
+
expect(frame).toContain('Navigation');
|
|
89
|
+
expect(frame).toContain('Playback');
|
|
90
|
+
expect(frame).toContain(':search');
|
|
91
|
+
expect(frame).toContain(':doctor');
|
|
92
|
+
expect(frame).toContain('Toggle this help');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('NowPlayingScreen rendering', () => {
|
|
96
|
+
it('shows recent tracks for the tuned station when diagnostics are open', () => {
|
|
97
|
+
const { lastFrame } = renderNowPlaying(false, true);
|
|
98
|
+
const frame = lastFrame() ?? '';
|
|
99
|
+
expect(frame).toContain('Bjork - Joga');
|
|
100
|
+
expect(frame).toContain('UNITED STATES');
|
|
101
|
+
});
|
|
102
|
+
it('uses Unicode box borders by default', () => {
|
|
103
|
+
const { lastFrame } = renderNowPlaying(false, false);
|
|
104
|
+
const frame = lastFrame() ?? '';
|
|
105
|
+
expect(/[\u2500-\u257f]/.test(frame)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
it('emits only ASCII characters in ASCII-safe mode', () => {
|
|
108
|
+
const { lastFrame } = renderNowPlaying(true, true);
|
|
109
|
+
const frame = lastFrame() ?? '';
|
|
110
|
+
expect(frame).toContain('Bjork - Joga');
|
|
111
|
+
// No braille, block, box-drawing, or punctuation glyphs survive ASCII mode.
|
|
112
|
+
expect(/[^\x00-\x7f]/.test(frame)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolveCommand } from '../player/command.js';
|
|
3
|
+
// The command used to open a URL in the platform's default handler.
|
|
4
|
+
export function openExternalCommand(platform = process.platform) {
|
|
5
|
+
if (platform === 'darwin') {
|
|
6
|
+
return { command: 'open', args: [] };
|
|
7
|
+
}
|
|
8
|
+
if (platform === 'win32') {
|
|
9
|
+
// The empty "" is the window title argument `start` expects before the URL.
|
|
10
|
+
return { command: 'cmd', args: ['/c', 'start', ''] };
|
|
11
|
+
}
|
|
12
|
+
return { command: 'xdg-open', args: [] };
|
|
13
|
+
}
|
|
14
|
+
// Candidate clipboard tools per platform, in priority order. pbcopy/clip ship
|
|
15
|
+
// with macOS/Windows; Linux/BSD rely on whichever of these is installed.
|
|
16
|
+
export function clipboardCommands(platform = process.platform) {
|
|
17
|
+
if (platform === 'darwin') {
|
|
18
|
+
return [{ command: 'pbcopy', args: [] }];
|
|
19
|
+
}
|
|
20
|
+
if (platform === 'win32') {
|
|
21
|
+
return [{ command: 'clip', args: [] }];
|
|
22
|
+
}
|
|
23
|
+
return [
|
|
24
|
+
{ command: 'wl-copy', args: [] },
|
|
25
|
+
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
26
|
+
{ command: 'xsel', args: ['--clipboard', '--input'] }
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
export function openExternal(url, platform = process.platform) {
|
|
30
|
+
const { command, args } = openExternalCommand(platform);
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(resolveCommand(command) ?? command, [...args, url], { stdio: 'ignore', detached: true });
|
|
33
|
+
child.on('error', () => undefined);
|
|
34
|
+
child.unref();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Opening a browser is best-effort; never crash the TUI over it.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function copyToClipboard(text, platform = process.platform) {
|
|
41
|
+
for (const { command, args } of clipboardCommands(platform)) {
|
|
42
|
+
const resolved = resolveCommand(command);
|
|
43
|
+
if (!resolved) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const child = spawn(resolved, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
48
|
+
child.on('error', () => undefined);
|
|
49
|
+
child.stdin.write(text);
|
|
50
|
+
child.stdin.end();
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -3,7 +3,8 @@ import { useInput } from 'ink';
|
|
|
3
3
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
4
4
|
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
|
|
5
5
|
import { parseSgrMouseEvents, primaryMousePress } 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
8
|
useEffect(() => {
|
|
8
9
|
const onData = (data) => {
|
|
9
10
|
const rawInput = String(data);
|
|
@@ -93,6 +94,11 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
93
94
|
setCommandMode(false);
|
|
94
95
|
return;
|
|
95
96
|
}
|
|
97
|
+
if (key.tab) {
|
|
98
|
+
// Complete the command name only while still typing it (no args yet).
|
|
99
|
+
setCommandText(value => (/\s/.test(value) ? value : completeCommand(value)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
96
102
|
if (isEditableInput(input, key)) {
|
|
97
103
|
setCommandText(value => applyTextInput(value, input, key));
|
|
98
104
|
}
|
|
@@ -132,6 +138,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
132
138
|
setEditingSearch(false);
|
|
133
139
|
return;
|
|
134
140
|
}
|
|
141
|
+
if (key.upArrow) {
|
|
142
|
+
recallSearchHistory('older');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (key.downArrow) {
|
|
146
|
+
recallSearchHistory('newer');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
135
149
|
if (isEditableInput(input, key)) {
|
|
136
150
|
setSearchQuery(value => applyTextInput(value, input, key));
|
|
137
151
|
setEditingSearch(true);
|
|
@@ -175,6 +189,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
175
189
|
shutdown();
|
|
176
190
|
return;
|
|
177
191
|
}
|
|
192
|
+
if (input === '?') {
|
|
193
|
+
go(screen === 'help' ? 'home' : 'help');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
178
196
|
if (input.startsWith(':')) {
|
|
179
197
|
const seed = input.slice(1).replace(/[\r\n]+$/g, '');
|
|
180
198
|
if (/[\r\n]/.test(input)) {
|
|
@@ -301,6 +319,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
301
319
|
toggleFavorite(favoriteTarget(screen, selectedStation, playingStation));
|
|
302
320
|
return;
|
|
303
321
|
}
|
|
322
|
+
if (input === 'O') {
|
|
323
|
+
openStationHomepage(favoriteTarget(screen, selectedStation, playingStation));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (input === 'y') {
|
|
327
|
+
void copyStationUrl(favoriteTarget(screen, selectedStation, playingStation));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
304
330
|
if (input === ' ') {
|
|
305
331
|
togglePause();
|
|
306
332
|
return;
|
|
@@ -376,6 +402,18 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
376
402
|
else if (item === 'Toggle skip broken streams') {
|
|
377
403
|
toggleSkipBrokenStreams();
|
|
378
404
|
}
|
|
405
|
+
else if (item === 'Resume last station on launch') {
|
|
406
|
+
toggleSetting('resumeOnLaunch');
|
|
407
|
+
}
|
|
408
|
+
else if (item === 'Transparent background') {
|
|
409
|
+
toggleSetting('transparentBackground');
|
|
410
|
+
}
|
|
411
|
+
else if (item === 'ASCII-safe display') {
|
|
412
|
+
toggleSetting('asciiMode');
|
|
413
|
+
}
|
|
414
|
+
else if (item === 'Reduce motion') {
|
|
415
|
+
toggleSetting('reduceMotion');
|
|
416
|
+
}
|
|
379
417
|
else if (item === 'Refresh provider health') {
|
|
380
418
|
refreshProviderHealth();
|
|
381
419
|
setMessage('Provider health refreshed.');
|
|
@@ -141,6 +141,28 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
141
141
|
await player.stop();
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
|
+
if (name === 'doctor') {
|
|
145
|
+
const backends = player.refreshDetectedBackends();
|
|
146
|
+
setMessage(`Playback backends: ${backends.join(', ') || 'none — install mpv, then run radiocli doctor'}.`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (name === 'help') {
|
|
150
|
+
go('help');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (name === 'resume' || name === 'ascii' || name === 'motion' || name === 'background') {
|
|
154
|
+
const key = name === 'resume'
|
|
155
|
+
? 'resumeOnLaunch'
|
|
156
|
+
: name === 'ascii'
|
|
157
|
+
? 'asciiMode'
|
|
158
|
+
: name === 'motion'
|
|
159
|
+
? 'reduceMotion'
|
|
160
|
+
: 'transparentBackground';
|
|
161
|
+
const enabled = value === '' ? !settingsRef.current[key] : value === 'on' || value === 'true' || value === '1';
|
|
162
|
+
updateSettings({ [key]: enabled });
|
|
163
|
+
setMessage(`${name} ${enabled ? 'on' : 'off'}.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
144
166
|
setMessage(`Unknown command: ${name}`);
|
|
145
167
|
}, [
|
|
146
168
|
beginLearningTransportKey,
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
let cachedVersion = null;
|
|
4
|
+
export function appVersion() {
|
|
5
|
+
if (cachedVersion) {
|
|
6
|
+
return cachedVersion;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
10
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
11
|
+
cachedVersion = parsed.version ?? '0.0.0';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
cachedVersion = '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
return cachedVersion;
|
|
17
|
+
}
|
|
18
|
+
export function userAgent(suffix = '') {
|
|
19
|
+
return `radiocli/${appVersion()}${suffix}`;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciphore/radiocli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@types/node": "^25.9.1",
|
|
77
77
|
"@types/react": "^19.2.15",
|
|
78
|
+
"ink-testing-library": "^4.0.0",
|
|
78
79
|
"knip": "^6.14.2",
|
|
79
80
|
"publint": "^0.3.21",
|
|
80
81
|
"tsx": "^4.22.3",
|