@ciphore/radiocli 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +109 -10
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -214
package/dist/ui/screen-items.js
CHANGED
|
@@ -4,28 +4,67 @@ export const homeItems = [
|
|
|
4
4
|
{ screen: 'explore', label: 'Explore', detail: 'Move a map cursor through geotagged stations' },
|
|
5
5
|
{ screen: 'search', label: 'Search', detail: 'Find stations by name, genre, language, place' },
|
|
6
6
|
{ screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
|
|
7
|
-
{ screen: 'nearby', label: 'Nearby', detail: '
|
|
7
|
+
{ screen: 'nearby', label: 'Nearby', detail: 'Approximate IP location for local stations' },
|
|
8
8
|
{ screen: 'stats', label: 'Stats', detail: 'Listening graph, stations, streaks, hours' },
|
|
9
9
|
{ screen: 'settings', label: 'Settings', detail: 'Audio output, colors, providers' }
|
|
10
10
|
];
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
11
|
+
export const settingsGroups = [
|
|
12
|
+
{
|
|
13
|
+
label: 'Playback',
|
|
14
|
+
items: [
|
|
15
|
+
'Audio output',
|
|
16
|
+
'AirPlay receiver',
|
|
17
|
+
'Volume up',
|
|
18
|
+
'Volume down',
|
|
19
|
+
'Mute or unmute',
|
|
20
|
+
'Toggle skip broken streams',
|
|
21
|
+
'Resume last station on launch'
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: 'Display',
|
|
26
|
+
items: [
|
|
27
|
+
'Cycle display color',
|
|
28
|
+
'Cycle receiver style',
|
|
29
|
+
'Transparent background',
|
|
30
|
+
'ASCII-safe display',
|
|
31
|
+
'Reduce motion',
|
|
32
|
+
'Mouse and trackpad scrolling'
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: 'Discovery & privacy',
|
|
37
|
+
items: [
|
|
38
|
+
'Toggle nearby location lookup',
|
|
39
|
+
'Toggle Radio Garden experimental adapter',
|
|
40
|
+
'Share favorite votes with Radio Browser'
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: 'Data',
|
|
45
|
+
items: [
|
|
46
|
+
'Export preferences and library',
|
|
47
|
+
'Import preferences and library'
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
label: 'Media keys',
|
|
52
|
+
items: [
|
|
53
|
+
'Learn previous media key',
|
|
54
|
+
'Learn play/pause media key',
|
|
55
|
+
'Learn next media key',
|
|
56
|
+
'Reset learned media keys'
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: 'Maintenance',
|
|
61
|
+
items: [
|
|
62
|
+
'Refresh provider health',
|
|
63
|
+
'Check for updates'
|
|
64
|
+
]
|
|
65
|
+
}
|
|
31
66
|
];
|
|
67
|
+
export const settingsItems = settingsGroups.flatMap(group => group.items);
|
|
68
|
+
export function settingsSectionFor(item) {
|
|
69
|
+
return settingsGroups.find(group => group.items.some(candidate => candidate === item))?.label ?? 'Preferences';
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const primaryScreens = [
|
|
2
|
+
'home',
|
|
3
|
+
'now-playing',
|
|
4
|
+
'library',
|
|
5
|
+
'explore',
|
|
6
|
+
'search',
|
|
7
|
+
'countries',
|
|
8
|
+
'nearby',
|
|
9
|
+
'stats',
|
|
10
|
+
'settings'
|
|
11
|
+
];
|
|
12
|
+
export const screenMetadata = {
|
|
13
|
+
home: { tabLabel: 'Overview', title: 'Overview', description: 'Your listening at a glance' },
|
|
14
|
+
'now-playing': { tabLabel: 'Playing', title: 'Now playing', description: 'Receiver and playback controls' },
|
|
15
|
+
library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, recents, and imported streams' },
|
|
16
|
+
explore: { tabLabel: 'Explore', title: 'Explore', description: 'Discover stations on the world map' },
|
|
17
|
+
search: { tabLabel: 'Search', title: 'Search', description: 'Find stations, genres, languages, or places' },
|
|
18
|
+
countries: { tabLabel: 'Countries', title: 'Countries', description: 'Browse stations by country' },
|
|
19
|
+
nearby: { tabLabel: 'Nearby', title: 'Nearby', description: 'Find stations near your approximate location' },
|
|
20
|
+
stats: { tabLabel: 'Stats', title: 'Listening stats', description: 'History, streaks, stations, and time' },
|
|
21
|
+
settings: { tabLabel: 'Settings', title: 'Settings', description: 'Playback, display, privacy, and data' }
|
|
22
|
+
};
|
|
23
|
+
const secondaryTitles = {
|
|
24
|
+
stations: 'Stations',
|
|
25
|
+
map: 'World map',
|
|
26
|
+
'airplay-settings': 'AirPlay',
|
|
27
|
+
'airplay-code': 'AirPlay code',
|
|
28
|
+
help: 'Help'
|
|
29
|
+
};
|
|
30
|
+
export function screenTitle(screen) {
|
|
31
|
+
if (screen in screenMetadata) {
|
|
32
|
+
return screenMetadata[screen].title;
|
|
33
|
+
}
|
|
34
|
+
return secondaryTitles[screen] ?? 'RadioCLI';
|
|
35
|
+
}
|
|
@@ -6,7 +6,8 @@ import { Menu, Pointer } from '../components/Menu.js';
|
|
|
6
6
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
7
7
|
import { truncate } from '../format.js';
|
|
8
8
|
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
9
|
-
|
|
9
|
+
import { visibleWindow } from '../list-window.js';
|
|
10
|
+
export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width, height }) {
|
|
10
11
|
const accent = themeAccent(theme);
|
|
11
12
|
const availability = airPlayAvailability(backends, devices);
|
|
12
13
|
const selectedDevice = selectedAirPlayDevice(settings, devices);
|
|
@@ -14,7 +15,8 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
|
|
|
14
15
|
const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
|
|
15
16
|
const lineWidth = Math.max(24, width - 4);
|
|
16
17
|
const preferredOutput = audioOutputLabel(settings.preferredBackend);
|
|
17
|
-
|
|
18
|
+
const deviceWindow = visibleWindow(devices, selected, height ? Math.max(1, height - 9) : Math.max(1, devices.length));
|
|
19
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: deviceWindow.items, selected: selected - deviceWindow.start, keyFor: device => device.id, render: (device, _index, active) => {
|
|
18
20
|
const isSelected = device.id === settings.preferredAirPlayDevice;
|
|
19
21
|
const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
|
|
20
22
|
const selectedLabel = isSelected ? ' selected' : '';
|
|
@@ -4,13 +4,16 @@ import { Menu, Pointer } from '../components/Menu.js';
|
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
5
|
import { textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
|
-
import { truncate } from '../format.js';
|
|
7
|
+
import { displayWidth, truncate } from '../format.js';
|
|
8
8
|
export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
|
|
9
9
|
const window = visibleWindow(countries, selected, pageSize);
|
|
10
10
|
const rowWidth = Math.max(24, width - 2);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const countWidth = Math.max(12, Math.min(22, Math.max(...countries.map(country => `${country.stationCount.toLocaleString()} stations`.length), 12)));
|
|
12
|
+
const visibleCountryWidth = Math.max(8, ...window.items.map(country => displayWidth(`${country.name} (${country.code})`)));
|
|
13
|
+
const countryWidth = Math.min(42, visibleCountryWidth, Math.max(8, rowWidth - countWidth - 4));
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Countries", subtitle: editingFilter ? 'Filtering countries — type to narrow the list' : 'Browse the worldwide country directory', width: width, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", countries.length ? window.start + 1 : 0, "-", window.end, " of ", countries.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => {
|
|
15
|
+
const code = ` (${country.code})`;
|
|
16
|
+
const nameWidth = Math.max(3, countryWidth - displayWidth(code));
|
|
17
|
+
return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: countryWidth, children: [_jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: truncate(country.name, nameWidth) }), _jsx(Text, { color: textMuted, children: code })] }), _jsx(Box, { width: 2 }), _jsx(Box, { width: countWidth, children: _jsxs(Text, { color: textMuted, children: [country.stationCount.toLocaleString(), " stations"] }) })] }));
|
|
15
18
|
} })] })) : null] }));
|
|
16
19
|
}
|
|
@@ -13,7 +13,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, th
|
|
|
13
13
|
const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
|
|
14
14
|
const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
|
|
15
15
|
const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
|
|
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: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }),
|
|
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: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2), showCount: false, emptyTitle: loading ? 'Loading stations…' : 'No stations near this point.', emptyHint: "Move the map cursor to explore another area." })] })] })] }));
|
|
17
17
|
}
|
|
18
18
|
function CosmoMapLine({ row, theme }) {
|
|
19
19
|
const { ascii } = useDisplay();
|
|
@@ -3,10 +3,24 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { textMuted, themeAccent } from '../theme.js';
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
5
|
import { commandHelp, keyHelpSections } from '../help-content.js';
|
|
6
|
-
import { truncate } from '../format.js';
|
|
7
|
-
|
|
6
|
+
import { padDisplayEnd, truncate } from '../format.js';
|
|
7
|
+
import { visibleWindow } from '../list-window.js';
|
|
8
|
+
export function HelpScreen({ theme, width, height, selected }) {
|
|
8
9
|
const accent = themeAccent(theme);
|
|
9
10
|
const keyColumnWidth = 16;
|
|
10
11
|
const lineWidth = Math.max(28, width - 2);
|
|
11
|
-
|
|
12
|
+
const rows = [
|
|
13
|
+
...keyHelpSections.flatMap(section => [
|
|
14
|
+
{ key: `section-${section.title}`, heading: section.title },
|
|
15
|
+
...section.entries.map(entry => ({ key: `${section.title}-${entry.keys}`, ...entry }))
|
|
16
|
+
]),
|
|
17
|
+
{ key: 'commands', heading: 'Commands (press : then type)' },
|
|
18
|
+
...commandHelp.map(command => ({
|
|
19
|
+
key: `command-${command.name}`,
|
|
20
|
+
keys: `:${command.name}${command.args ? ` ${command.args}` : ''}`,
|
|
21
|
+
description: command.description
|
|
22
|
+
}))
|
|
23
|
+
];
|
|
24
|
+
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))) }), _jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", rows.length, " \u00B7 \u2191/\u2193 scroll"] })] }));
|
|
12
26
|
}
|
|
@@ -4,7 +4,6 @@ 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
|
-
|
|
8
|
-
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _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(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
|
|
7
|
+
export function HomeScreen({ selected, theme, library }) {
|
|
8
|
+
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(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
|
|
10
9
|
}
|
|
@@ -6,7 +6,7 @@ import { toAsciiSafe } from '../ascii.js';
|
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
7
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
8
8
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
9
|
-
import { truncate } from '../format.js';
|
|
9
|
+
import { padDisplayEnd, truncate } from '../format.js';
|
|
10
10
|
import { buildWorldMap } from '../world-map.js';
|
|
11
11
|
export function MapScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, mode, width }) {
|
|
12
12
|
const { ascii } = useDisplay();
|
|
@@ -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
|
|
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))}`) })] })) }) })] })] }));
|
|
22
22
|
}
|
|
23
23
|
function MapLine({ row, theme }) {
|
|
24
24
|
const { ascii } = useDisplay();
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { displayWidth, stationLocation, truncate } from '../format.js';
|
|
4
4
|
import { textMuted, themeAccent } from '../theme.js';
|
|
5
5
|
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
6
6
|
import { toAsciiSafe } from '../ascii.js';
|
|
7
7
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
8
8
|
import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
|
|
9
|
-
|
|
9
|
+
// Terminal rows are roughly twice as tall as columns are wide. One vertical
|
|
10
|
+
// row and three horizontal columns produce a closer optical inset around the
|
|
11
|
+
// highest and lowest visible text than a mechanically equal cell count.
|
|
12
|
+
const receiverPaddingX = 3;
|
|
13
|
+
const receiverPaddingY = 1;
|
|
14
|
+
export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
|
|
10
15
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
11
16
|
// In ASCII mode route every rendered string through the glyph mapper so no
|
|
12
17
|
// braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
|
|
@@ -15,30 +20,29 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
15
20
|
const accent = themeAccent(theme);
|
|
16
21
|
const panelWidth = Math.max(62, width);
|
|
17
22
|
const panelHeight = Math.max(10, height);
|
|
18
|
-
const innerWidth = Math.max(28, panelWidth -
|
|
19
|
-
|
|
23
|
+
const innerWidth = Math.max(28, panelWidth - (receiverPaddingX * 2) - 2);
|
|
24
|
+
// Station identity now lives in the receiver header. Its former name and
|
|
25
|
+
// location rows become visualizer space, while a shared gutter keeps the
|
|
26
|
+
// visualization equally separated from the header and track title.
|
|
27
|
+
const visualizerGutterRows = 1;
|
|
28
|
+
const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
|
|
20
29
|
const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
: 'Choose a station from Library, Explore, Search, Countries, or Nearby.';
|
|
30
|
+
const receiverState = playback.state.toUpperCase();
|
|
31
|
+
const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
|
|
32
|
+
const stationIdentity = receiverStationIdentity(station, identityWidth);
|
|
25
33
|
const infoFallback = diagnostics.availableBackends.length > 0
|
|
26
34
|
? 'Playback backend ready. Choose a station to start tuning.'
|
|
27
35
|
: 'No playback backend found. Run radiocli doctor for setup help.';
|
|
28
|
-
// One compact meta line: stream tech, tags, and the sleep timer only when set.
|
|
29
|
-
const sleepSuffix = sleepLabel !== 'Sleep off' ? ` · ${sleepLabel}` : '';
|
|
30
|
-
const infoLine = station
|
|
31
|
-
? truncate(`${stationTech(station)} · ${stationTags(station)}${sleepSuffix}`, innerWidth)
|
|
32
|
-
: infoFallback;
|
|
33
36
|
const metadataLine = metadata?.title
|
|
34
37
|
? truncate(metadata.title, Math.max(8, innerWidth - 12))
|
|
35
|
-
:
|
|
36
|
-
|
|
38
|
+
: station
|
|
39
|
+
? 'Waiting for ICY track metadata'
|
|
40
|
+
: infoFallback;
|
|
37
41
|
const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
|
|
38
42
|
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:
|
|
43
|
+
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
|
|
40
44
|
? renderSegments(row.segments)
|
|
41
|
-
: row.text },
|
|
45
|
+
: 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] })] }));
|
|
42
46
|
}
|
|
43
47
|
function asciifyVisualRow(row) {
|
|
44
48
|
return {
|
|
@@ -54,43 +58,19 @@ export function recentTracksForStation(history, station, limit) {
|
|
|
54
58
|
const key = `${station.provider}:${station.id}`;
|
|
55
59
|
return history.filter(track => track.stationKey === key).slice(0, limit);
|
|
56
60
|
}
|
|
57
|
-
export function
|
|
61
|
+
export function receiverStationIdentity(station, width) {
|
|
58
62
|
if (!station) {
|
|
59
|
-
return '
|
|
63
|
+
return { name: truncate('No station tuned', width), location: '' };
|
|
60
64
|
}
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
65
|
+
const nameWidth = displayWidth(station.name);
|
|
66
|
+
if (nameWidth >= width - 3) {
|
|
67
|
+
return { name: truncate(station.name, width), location: '' };
|
|
64
68
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return `FM ${String(station.bitrate).padStart(3, '0')}`;
|
|
71
|
-
}
|
|
72
|
-
if (codec) {
|
|
73
|
-
return `FM ${codec}`;
|
|
74
|
-
}
|
|
75
|
-
return 'FM LIVE';
|
|
76
|
-
}
|
|
77
|
-
function stationFrequency(name) {
|
|
78
|
-
const frequencyMatch = name.match(/\b(?:(AM|FM)\s*)?(\d{2,4}(?:\.\d{1,2})?)(?:\s*(AM|FM))?\b/i);
|
|
79
|
-
if (!frequencyMatch) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
const value = Number(frequencyMatch[2]);
|
|
83
|
-
if (!Number.isFinite(value)) {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
const explicitBand = (frequencyMatch[1] ?? frequencyMatch[3])?.toUpperCase();
|
|
87
|
-
if (explicitBand === 'AM' || (!explicitBand && value >= 520 && value <= 1710)) {
|
|
88
|
-
return `AM ${frequencyMatch[2]}`;
|
|
89
|
-
}
|
|
90
|
-
if (explicitBand === 'FM' || (!explicitBand && value >= 65 && value <= 120)) {
|
|
91
|
-
return `FM ${frequencyMatch[2]}`;
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
69
|
+
const locationWidth = Math.max(0, width - nameWidth - 3);
|
|
70
|
+
return {
|
|
71
|
+
name: station.name,
|
|
72
|
+
location: truncate(stationLocation(station).toUpperCase(), locationWidth)
|
|
73
|
+
};
|
|
94
74
|
}
|
|
95
75
|
function renderSegments(segments) {
|
|
96
76
|
let offset = 0;
|
|
@@ -10,5 +10,5 @@ export function SearchScreen({ query, editing, loading, stations, selected, them
|
|
|
10
10
|
const contentWidth = Math.max(40, width);
|
|
11
11
|
const inputWidth = Math.max(34, Math.min(contentWidth, 96));
|
|
12
12
|
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: "Searching
|
|
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: "Searching station directories\u2026" }) : 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
14
|
}
|
|
@@ -1,23 +1,73 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
5
|
import { truncate } from '../format.js';
|
|
6
|
-
import { settingsItems } from '../screen-items.js';
|
|
6
|
+
import { settingsItems, settingsSectionFor } from '../screen-items.js';
|
|
7
7
|
import { textMuted, themeAccent } from '../theme.js';
|
|
8
8
|
import { playbackBackendCapabilities } from '../../player/backend-install.js';
|
|
9
9
|
import { airPlayReceiverSettingValue } from '../airplay-settings.js';
|
|
10
10
|
import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
|
|
11
|
-
|
|
11
|
+
import { updateStatusText } from '../../update-check.js';
|
|
12
|
+
export function SettingsScreen({ selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
|
|
12
13
|
const accent = themeAccent(theme);
|
|
13
14
|
const lineWidth = Math.max(32, width - 4);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const menuWindow = settingsMenuWindow(selected, height);
|
|
16
|
+
const selectedItem = settingsItems[selected];
|
|
17
|
+
const activeSection = settingsSectionFor(selectedItem);
|
|
18
|
+
const detail = settingDetail({
|
|
19
|
+
item: selectedItem,
|
|
20
|
+
appVersion,
|
|
21
|
+
updateCheck,
|
|
22
|
+
storePath,
|
|
23
|
+
playback,
|
|
24
|
+
providerHealth,
|
|
25
|
+
diagnostics,
|
|
26
|
+
lineWidth
|
|
27
|
+
});
|
|
28
|
+
const valueGap = 3;
|
|
29
|
+
const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
|
|
30
|
+
const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: activeSection, 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) => {
|
|
32
|
+
const label = settingLabel(item.item, updateCheck);
|
|
33
|
+
const value = settingValue(item.item, settings, diagnostics, backends, airPlayDevices, updateCheck);
|
|
34
|
+
return (_jsxs(Box, { flexDirection: "column", children: [item.showSection ? (_jsx(Text, { color: textMuted, bold: true, children: item.section })) : null, _jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] })] }));
|
|
35
|
+
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
|
|
19
36
|
}
|
|
20
|
-
function
|
|
37
|
+
function settingsMenuWindow(selected, height) {
|
|
38
|
+
const reservedRows = 6;
|
|
39
|
+
const maxRows = height ? Math.max(5, height - reservedRows) : Number.POSITIVE_INFINITY;
|
|
40
|
+
const clampedSelected = Math.min(Math.max(selected, 0), settingsItems.length - 1);
|
|
41
|
+
const pages = [];
|
|
42
|
+
let page = [];
|
|
43
|
+
let pageRows = 0;
|
|
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] ?? [];
|
|
65
|
+
return {
|
|
66
|
+
items: selectedPage,
|
|
67
|
+
selectedOffset: Math.max(0, selectedPage.findIndex(entry => entry.index === clampedSelected))
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
|
|
21
71
|
switch (item) {
|
|
22
72
|
case 'Cycle display color':
|
|
23
73
|
return settings.theme;
|
|
@@ -27,12 +77,14 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
|
|
|
27
77
|
return settings.enableRadioGarden ? 'on' : 'off';
|
|
28
78
|
case 'Toggle nearby location lookup':
|
|
29
79
|
return settings.enableNearbyLocation ? 'on' : 'off';
|
|
80
|
+
case 'Share favorite votes with Radio Browser':
|
|
81
|
+
return settings.shareDirectoryVotes ? 'on' : 'off';
|
|
30
82
|
case 'Audio output':
|
|
31
83
|
return audioOutputSettingValue(settings, diagnostics, backends);
|
|
32
84
|
case 'AirPlay receiver':
|
|
33
85
|
return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
|
|
34
86
|
case 'Mute or unmute':
|
|
35
|
-
if (
|
|
87
|
+
if (!playbackBackendCapabilities(diagnostics.backend).supportsMute) {
|
|
36
88
|
return 'requires mpv';
|
|
37
89
|
}
|
|
38
90
|
return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
|
|
@@ -46,9 +98,56 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
|
|
|
46
98
|
return settings.asciiMode ? 'on' : 'off';
|
|
47
99
|
case 'Reduce motion':
|
|
48
100
|
return settings.reduceMotion ? 'on' : 'off';
|
|
101
|
+
case 'Mouse and trackpad scrolling':
|
|
102
|
+
return settings.mouseSupport === false ? 'off' : 'auto';
|
|
103
|
+
case 'Check for updates':
|
|
104
|
+
return updateStatusText(updateCheck);
|
|
49
105
|
case 'Reset learned media keys':
|
|
50
106
|
return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
|
|
107
|
+
case 'Export preferences and library':
|
|
108
|
+
return 'JSON backup';
|
|
109
|
+
case 'Import preferences and library':
|
|
110
|
+
return 'restore JSON backup';
|
|
51
111
|
default:
|
|
52
112
|
return undefined;
|
|
53
113
|
}
|
|
54
114
|
}
|
|
115
|
+
export function settingLabel(item, updateCheck) {
|
|
116
|
+
if (item === 'Check for updates' && updateCheck?.updateAvailable) {
|
|
117
|
+
return 'Install update';
|
|
118
|
+
}
|
|
119
|
+
return settingDisplayLabels[item] ?? item;
|
|
120
|
+
}
|
|
121
|
+
const settingDisplayLabels = {
|
|
122
|
+
'Cycle display color': 'Display color',
|
|
123
|
+
'Cycle receiver style': 'Receiver style',
|
|
124
|
+
'Toggle nearby location lookup': 'Nearby location',
|
|
125
|
+
'Toggle Radio Garden experimental adapter': 'Radio Garden adapter',
|
|
126
|
+
'Toggle skip broken streams': 'Skip broken streams'
|
|
127
|
+
};
|
|
128
|
+
function settingDetail(input) {
|
|
129
|
+
const { item, appVersion, updateCheck, storePath, playback, providerHealth, diagnostics, lineWidth } = input;
|
|
130
|
+
if (item === 'Audio output') {
|
|
131
|
+
return `Active: ${audioOutputLabel(playback.backend)} · ${playback.state} · ${diagnostics.muted ? 'muted' : `volume ${diagnostics.volume}`}`;
|
|
132
|
+
}
|
|
133
|
+
if (item === 'Refresh provider health') {
|
|
134
|
+
const health = Object.entries(providerHealth);
|
|
135
|
+
return health.length ? health.map(([provider, status]) => `${provider}: ${status}`).join(' · ') : 'Press Enter to check station providers.';
|
|
136
|
+
}
|
|
137
|
+
if (item === 'Check for updates') {
|
|
138
|
+
return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck)}`;
|
|
139
|
+
}
|
|
140
|
+
if (item === 'Export preferences and library' || item === 'Import preferences and library') {
|
|
141
|
+
return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
|
|
142
|
+
}
|
|
143
|
+
if (item === 'Share favorite votes with Radio Browser') {
|
|
144
|
+
return 'When enabled, favoriting also sends a public directory vote.';
|
|
145
|
+
}
|
|
146
|
+
if (item === 'Toggle nearby location lookup') {
|
|
147
|
+
return 'Uses approximate IP location only when you open Nearby.';
|
|
148
|
+
}
|
|
149
|
+
if (item === 'Reduce motion') {
|
|
150
|
+
return 'Freezes animated receiver displays for calmer, lower-power rendering.';
|
|
151
|
+
}
|
|
152
|
+
return 'Press Enter to change this setting.';
|
|
153
|
+
}
|
|
@@ -4,5 +4,18 @@ import { StationList } from '../components/StationList.js';
|
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
5
|
import { textMuted } from '../theme.js';
|
|
6
6
|
export function StationScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, pageSize, width }) {
|
|
7
|
-
|
|
7
|
+
const empty = stationEmptyState(title);
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: textMuted, children: "Loading stations\u2026" }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: empty.title, emptyHint: empty.hint })) : null] })] }));
|
|
9
|
+
}
|
|
10
|
+
function stationEmptyState(title) {
|
|
11
|
+
if (title.toLowerCase().includes('library')) {
|
|
12
|
+
return { title: 'Your library is empty.', hint: 'Press f on any station to save it here.' };
|
|
13
|
+
}
|
|
14
|
+
if (title.toLowerCase().includes('nearby')) {
|
|
15
|
+
return { title: 'No nearby stations found.', hint: 'Try again later or browse Countries.' };
|
|
16
|
+
}
|
|
17
|
+
if (title.toLowerCase().includes('country')) {
|
|
18
|
+
return { title: 'No stations found for this country.', hint: 'Choose another country or clear active filters.' };
|
|
19
|
+
}
|
|
20
|
+
return { title: 'No stations found.', hint: 'Try another view or clear active filters.' };
|
|
8
21
|
}
|