@ciphore/radiocli 0.1.5 → 0.1.7
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 +39 -0
- package/README.md +7 -5
- 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 +10 -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 +25 -11
- package/dist/ui/screens/screen-render.test.js +152 -0
- package/dist/ui/system-actions.js +58 -0
- package/dist/ui/use-app-input.js +48 -2
- package/dist/ui/use-command-executor.js +22 -0
- package/dist/version.js +20 -0
- package/package.json +2 -1
package/dist/ui/page-footer.js
CHANGED
|
@@ -10,7 +10,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
10
10
|
return '↑/↓ move · Enter open · number jump · : command';
|
|
11
11
|
}
|
|
12
12
|
if (screen === 'search' && editingSearch) {
|
|
13
|
-
return 'Type query ·
|
|
13
|
+
return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
|
|
14
14
|
}
|
|
15
15
|
if (screen === 'search') {
|
|
16
16
|
return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b home';
|
|
@@ -55,5 +55,8 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
55
55
|
if (screen === 'stats') {
|
|
56
56
|
return 'b home';
|
|
57
57
|
}
|
|
58
|
+
if (screen === 'help') {
|
|
59
|
+
return '? or b close · : command';
|
|
60
|
+
}
|
|
58
61
|
return ': command';
|
|
59
62
|
}
|
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,14 @@
|
|
|
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'];
|
|
9
|
+
const heatmapSquare = '■';
|
|
8
10
|
export function StatsScreen({ library, theme, width, height }) {
|
|
11
|
+
const { panel: panelBackground, ascii } = useDisplay();
|
|
9
12
|
const stats = computeListeningStats(library.activity.sessions);
|
|
10
13
|
const contentWidth = Math.max(20, width - 4);
|
|
11
14
|
const graph = buildContributionGraph(stats.days, contentWidth);
|
|
@@ -15,7 +18,7 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
15
18
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
16
19
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
17
20
|
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:
|
|
21
|
+
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: [heatmapSquare, _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
22
|
? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
|
|
20
23
|
: 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
|
|
21
24
|
}
|
|
@@ -25,34 +28,45 @@ function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, t
|
|
|
25
28
|
return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [leftLabel, ": "] }), _jsx(Text, { color: accent, children: leftValue })] }) }), _jsx(Text, { children: ' '.repeat(leftPadding > 2 ? 2 : leftPadding) }), _jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [rightLabel, ": "] }), _jsx(Text, { color: accent, children: rightValue })] }) })] }));
|
|
26
29
|
}
|
|
27
30
|
function buildContributionGraph(days, width) {
|
|
28
|
-
const cellWidth = width >=
|
|
31
|
+
const cellWidth = width >= 112 ? 2 : 1;
|
|
29
32
|
const weeks = Array.from({ length: 53 }, (_, weekIndex) => days.slice(weekIndex * 7, weekIndex * 7 + 7)).filter(week => week.length > 0);
|
|
30
|
-
const
|
|
33
|
+
const scaleSeconds = contributionScaleSeconds(days);
|
|
31
34
|
const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
|
|
32
35
|
const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
|
|
33
36
|
key: `day-${dayIndex}`,
|
|
34
37
|
label: labels[dayIndex] ?? '',
|
|
35
38
|
cells: weeks.map(week => {
|
|
36
39
|
const day = week[dayIndex];
|
|
37
|
-
const level = contributionLevel(day?.seconds ?? 0,
|
|
38
|
-
const text = cellWidth > 1 ? `${
|
|
40
|
+
const level = contributionLevel(day?.seconds ?? 0, scaleSeconds);
|
|
41
|
+
const text = cellWidth > 1 ? `${heatmapSquare} ` : heatmapSquare;
|
|
39
42
|
return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text };
|
|
40
43
|
})
|
|
41
44
|
}));
|
|
42
45
|
return { months: monthLine(weeks, cellWidth), rows };
|
|
43
46
|
}
|
|
44
|
-
function
|
|
47
|
+
export function contributionScaleSeconds(days) {
|
|
48
|
+
const activeSeconds = days
|
|
49
|
+
.map(day => day.seconds)
|
|
50
|
+
.filter(seconds => seconds > 0)
|
|
51
|
+
.sort((left, right) => left - right);
|
|
52
|
+
if (activeSeconds.length === 0) {
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const percentileIndex = Math.min(activeSeconds.length - 1, Math.ceil(activeSeconds.length * 0.95) - 1);
|
|
56
|
+
return Math.max(1, activeSeconds[percentileIndex] ?? activeSeconds[activeSeconds.length - 1] ?? 1);
|
|
57
|
+
}
|
|
58
|
+
export function contributionLevel(seconds, scaleSeconds) {
|
|
45
59
|
if (seconds <= 0) {
|
|
46
60
|
return 0;
|
|
47
61
|
}
|
|
48
|
-
const ratio = seconds /
|
|
49
|
-
if (ratio
|
|
62
|
+
const ratio = seconds / Math.max(1, scaleSeconds);
|
|
63
|
+
if (ratio >= 0.75) {
|
|
50
64
|
return 4;
|
|
51
65
|
}
|
|
52
|
-
if (ratio
|
|
66
|
+
if (ratio >= 0.5) {
|
|
53
67
|
return 3;
|
|
54
68
|
}
|
|
55
|
-
if (ratio
|
|
69
|
+
if (ratio >= 0.25) {
|
|
56
70
|
return 2;
|
|
57
71
|
}
|
|
58
72
|
return 1;
|
|
@@ -0,0 +1,152 @@
|
|
|
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 { contributionLevel, contributionScaleSeconds, StatsScreen } from './StatsScreen.js';
|
|
10
|
+
import { settingsItems } from '../screen-items.js';
|
|
11
|
+
import { defaultExploreCursor } from '../app-state.js';
|
|
12
|
+
const station = {
|
|
13
|
+
id: 'station-1',
|
|
14
|
+
provider: 'radio-browser',
|
|
15
|
+
name: 'KEXP 90.3 FM',
|
|
16
|
+
country: 'United States',
|
|
17
|
+
tags: ['indie'],
|
|
18
|
+
codec: 'MP3',
|
|
19
|
+
bitrate: 128
|
|
20
|
+
};
|
|
21
|
+
const playback = {
|
|
22
|
+
backend: 'mpv',
|
|
23
|
+
state: 'idle',
|
|
24
|
+
volume: 70,
|
|
25
|
+
muted: false,
|
|
26
|
+
ready: false
|
|
27
|
+
};
|
|
28
|
+
const diagnostics = {
|
|
29
|
+
backend: 'mpv',
|
|
30
|
+
availableBackends: ['mpv'],
|
|
31
|
+
preferredBackend: 'auto',
|
|
32
|
+
active: false,
|
|
33
|
+
volume: 70,
|
|
34
|
+
muted: false,
|
|
35
|
+
ready: false
|
|
36
|
+
};
|
|
37
|
+
const trackHistory = [
|
|
38
|
+
{ title: 'Bjork - Joga', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '2' },
|
|
39
|
+
{ title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '1' }
|
|
40
|
+
];
|
|
41
|
+
function renderNowPlaying(asciiMode, showDiagnostics) {
|
|
42
|
+
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
43
|
+
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 }) }));
|
|
44
|
+
}
|
|
45
|
+
const settings = {
|
|
46
|
+
theme: 'green',
|
|
47
|
+
receiverStyle: 'pulse-grid',
|
|
48
|
+
volume: 70,
|
|
49
|
+
enableRadioGarden: false,
|
|
50
|
+
enableNearbyLocation: false,
|
|
51
|
+
preferredBackend: 'auto',
|
|
52
|
+
tuneTimeoutSeconds: 12,
|
|
53
|
+
skipBrokenStreams: true,
|
|
54
|
+
mediaKeys: { previous: [], playPause: [], next: [] },
|
|
55
|
+
resumeOnLaunch: true,
|
|
56
|
+
asciiMode: true,
|
|
57
|
+
reduceMotion: false,
|
|
58
|
+
transparentBackground: false
|
|
59
|
+
};
|
|
60
|
+
describe('SettingsScreen rendering', () => {
|
|
61
|
+
it('renders the new display and playback toggles with their values', () => {
|
|
62
|
+
const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
|
|
63
|
+
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 }));
|
|
64
|
+
const frame = lastFrame() ?? '';
|
|
65
|
+
expect(frame).toContain('Resume last station on launch');
|
|
66
|
+
expect(frame).toContain('ASCII-safe display');
|
|
67
|
+
expect(frame).toContain('Reduce motion');
|
|
68
|
+
expect(frame).toContain('Transparent background');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
function renderExplore(asciiMode) {
|
|
72
|
+
const mode = resolveDisplayMode({ asciiMode }, {});
|
|
73
|
+
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 }) }));
|
|
74
|
+
}
|
|
75
|
+
describe('Explore world map rendering', () => {
|
|
76
|
+
it('rasterizes land with braille glyphs by default', () => {
|
|
77
|
+
const frame = renderExplore(false).lastFrame() ?? '';
|
|
78
|
+
expect(/[⠀-⣿]/.test(frame)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it('replaces braille with ASCII in ASCII-safe mode', () => {
|
|
81
|
+
const frame = renderExplore(true).lastFrame() ?? '';
|
|
82
|
+
expect(/[⠀-⣿]/.test(frame)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('StatsScreen rendering', () => {
|
|
86
|
+
it('renders activity heatmap days as square markers instead of stretched blocks', () => {
|
|
87
|
+
const library = {
|
|
88
|
+
recent: [],
|
|
89
|
+
favorites: [],
|
|
90
|
+
imported: [],
|
|
91
|
+
trackHistory: [],
|
|
92
|
+
searchHistory: [],
|
|
93
|
+
activity: {
|
|
94
|
+
sessions: [
|
|
95
|
+
{
|
|
96
|
+
id: 'listen',
|
|
97
|
+
station,
|
|
98
|
+
startedAt: new Date(2026, 4, 24, 12).toISOString(),
|
|
99
|
+
endedAt: new Date(2026, 4, 24, 13).toISOString(),
|
|
100
|
+
listenedSeconds: 3600
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
settings
|
|
105
|
+
};
|
|
106
|
+
const frame = render(_jsx(DisplayContext.Provider, { value: resolveDisplayMode({ asciiMode: false }, {}), children: _jsx(StatsScreen, { library: library, theme: "green", width: 132, height: 32 }) })).lastFrame() ?? '';
|
|
107
|
+
expect(frame).toContain('■');
|
|
108
|
+
expect(frame).not.toContain('██');
|
|
109
|
+
});
|
|
110
|
+
it('caps contribution color scaling so one outlier does not flatten normal active days', () => {
|
|
111
|
+
const days = [
|
|
112
|
+
...Array.from({ length: 19 }, (_, index) => ({ date: `2026-05-${String(index + 1).padStart(2, '0')}`, seconds: 3600 })),
|
|
113
|
+
{ date: '2026-05-20', seconds: 36_000 }
|
|
114
|
+
];
|
|
115
|
+
const scaleSeconds = contributionScaleSeconds(days);
|
|
116
|
+
expect(scaleSeconds).toBe(3600);
|
|
117
|
+
expect(contributionLevel(3600, scaleSeconds)).toBe(4);
|
|
118
|
+
expect(contributionLevel(900, scaleSeconds)).toBe(2);
|
|
119
|
+
expect(contributionLevel(0, scaleSeconds)).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('HelpScreen rendering', () => {
|
|
123
|
+
it('lists keybinding sections and : commands', () => {
|
|
124
|
+
const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
|
|
125
|
+
const frame = lastFrame() ?? '';
|
|
126
|
+
expect(frame).toContain('Navigation');
|
|
127
|
+
expect(frame).toContain('Playback');
|
|
128
|
+
expect(frame).toContain(':search');
|
|
129
|
+
expect(frame).toContain(':doctor');
|
|
130
|
+
expect(frame).toContain('Toggle this help');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('NowPlayingScreen rendering', () => {
|
|
134
|
+
it('shows recent tracks for the tuned station when diagnostics are open', () => {
|
|
135
|
+
const { lastFrame } = renderNowPlaying(false, true);
|
|
136
|
+
const frame = lastFrame() ?? '';
|
|
137
|
+
expect(frame).toContain('Bjork - Joga');
|
|
138
|
+
expect(frame).toContain('UNITED STATES');
|
|
139
|
+
});
|
|
140
|
+
it('uses Unicode box borders by default', () => {
|
|
141
|
+
const { lastFrame } = renderNowPlaying(false, false);
|
|
142
|
+
const frame = lastFrame() ?? '';
|
|
143
|
+
expect(/[\u2500-\u257f]/.test(frame)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
it('emits only ASCII characters in ASCII-safe mode', () => {
|
|
146
|
+
const { lastFrame } = renderNowPlaying(true, true);
|
|
147
|
+
const frame = lastFrame() ?? '';
|
|
148
|
+
expect(frame).toContain('Bjork - Joga');
|
|
149
|
+
// No braille, block, box-drawing, or punctuation glyphs survive ASCII mode.
|
|
150
|
+
expect(/[^\x00-\x7f]/.test(frame)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { useInput } from 'ink';
|
|
3
3
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
4
|
-
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
|
|
4
|
+
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, searchEditingArrowAction, 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,22 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
132
138
|
setEditingSearch(false);
|
|
133
139
|
return;
|
|
134
140
|
}
|
|
141
|
+
const arrowAction = searchEditingArrowAction(key, currentItemCount('search') > 0);
|
|
142
|
+
if (arrowAction) {
|
|
143
|
+
if (arrowAction === 'history-older') {
|
|
144
|
+
recallSearchHistory('older');
|
|
145
|
+
}
|
|
146
|
+
else if (arrowAction === 'history-newer') {
|
|
147
|
+
recallSearchHistory('newer');
|
|
148
|
+
}
|
|
149
|
+
else if (arrowAction === 'select-previous') {
|
|
150
|
+
setSelected(value => clamp(value - 1, currentItemCount(screen) - 1));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
setSelected(value => clamp(value + 1, currentItemCount(screen) - 1));
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
135
157
|
if (isEditableInput(input, key)) {
|
|
136
158
|
setSearchQuery(value => applyTextInput(value, input, key));
|
|
137
159
|
setEditingSearch(true);
|
|
@@ -175,6 +197,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
175
197
|
shutdown();
|
|
176
198
|
return;
|
|
177
199
|
}
|
|
200
|
+
if (input === '?') {
|
|
201
|
+
go(screen === 'help' ? 'home' : 'help');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
178
204
|
if (input.startsWith(':')) {
|
|
179
205
|
const seed = input.slice(1).replace(/[\r\n]+$/g, '');
|
|
180
206
|
if (/[\r\n]/.test(input)) {
|
|
@@ -301,6 +327,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
301
327
|
toggleFavorite(favoriteTarget(screen, selectedStation, playingStation));
|
|
302
328
|
return;
|
|
303
329
|
}
|
|
330
|
+
if (input === 'O') {
|
|
331
|
+
openStationHomepage(favoriteTarget(screen, selectedStation, playingStation));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (input === 'y') {
|
|
335
|
+
void copyStationUrl(favoriteTarget(screen, selectedStation, playingStation));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
304
338
|
if (input === ' ') {
|
|
305
339
|
togglePause();
|
|
306
340
|
return;
|
|
@@ -376,6 +410,18 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
|
|
|
376
410
|
else if (item === 'Toggle skip broken streams') {
|
|
377
411
|
toggleSkipBrokenStreams();
|
|
378
412
|
}
|
|
413
|
+
else if (item === 'Resume last station on launch') {
|
|
414
|
+
toggleSetting('resumeOnLaunch');
|
|
415
|
+
}
|
|
416
|
+
else if (item === 'Transparent background') {
|
|
417
|
+
toggleSetting('transparentBackground');
|
|
418
|
+
}
|
|
419
|
+
else if (item === 'ASCII-safe display') {
|
|
420
|
+
toggleSetting('asciiMode');
|
|
421
|
+
}
|
|
422
|
+
else if (item === 'Reduce motion') {
|
|
423
|
+
toggleSetting('reduceMotion');
|
|
424
|
+
}
|
|
379
425
|
else if (item === 'Refresh provider health') {
|
|
380
426
|
refreshProviderHealth();
|
|
381
427
|
setMessage('Provider health refreshed.');
|