@ciphore/radiocli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
package/dist/ui/AppContent.js
CHANGED
|
@@ -15,7 +15,7 @@ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
|
|
|
15
15
|
import { selectedAirPlayDevice } from './airplay-settings.js';
|
|
16
16
|
import { AdaptiveContent } from './AdaptiveContent.js';
|
|
17
17
|
import { AlarmsScreen, AlarmEditorScreen, AlarmPickerScreen, AlarmRingingScreen } from './screens/AlarmsScreen.js';
|
|
18
|
-
export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui }) {
|
|
18
|
+
export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui, airPlaySupported = true }) {
|
|
19
19
|
if (layout.compact) {
|
|
20
20
|
if (screen === 'alarms')
|
|
21
21
|
return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
|
|
@@ -25,7 +25,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
|
|
|
25
25
|
return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
26
26
|
if (screen === 'alarm-ringing')
|
|
27
27
|
return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
28
|
-
return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel }));
|
|
28
|
+
return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel, airPlaySupported: airPlaySupported }));
|
|
29
29
|
}
|
|
30
30
|
if (screen === 'alarms')
|
|
31
31
|
return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
|
|
@@ -39,7 +39,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
|
|
|
39
39
|
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library });
|
|
40
40
|
}
|
|
41
41
|
if (screen === 'countries') {
|
|
42
|
-
return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth }));
|
|
42
|
+
return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth, reduceMotion: Boolean(library.settings.reduceMotion) }));
|
|
43
43
|
}
|
|
44
44
|
if (screen === 'map') {
|
|
45
45
|
return (_jsx(MapScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.mapCountryRows, mode: layout.mapMode, width: frameWidth }));
|
|
@@ -60,7 +60,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
|
|
|
60
60
|
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
61
61
|
}
|
|
62
62
|
if (screen === 'settings') {
|
|
63
|
-
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
|
|
63
|
+
return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows, airPlaySupported: airPlaySupported }));
|
|
64
64
|
}
|
|
65
65
|
if (screen === 'airplay-settings') {
|
|
66
66
|
return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
|
package/dist/ui/app-state.js
CHANGED
|
@@ -38,7 +38,7 @@ export const initialStationContexts = {
|
|
|
38
38
|
},
|
|
39
39
|
library: {
|
|
40
40
|
title: 'Library',
|
|
41
|
-
subtitle: 'Favorites,
|
|
41
|
+
subtitle: 'Favorites, imported streams, and recent stations',
|
|
42
42
|
stations: []
|
|
43
43
|
}
|
|
44
44
|
};
|
|
@@ -58,6 +58,13 @@ export function searchEditingArrowAction(key, hasResults) {
|
|
|
58
58
|
}
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
|
+
export function shouldTuneSearchResult(query, lastSubmittedQuery) {
|
|
62
|
+
const trimmedQuery = query.trim();
|
|
63
|
+
return !trimmedQuery || trimmedQuery === lastSubmittedQuery.trim();
|
|
64
|
+
}
|
|
65
|
+
export function isSearchFavoriteShortcut(input, key) {
|
|
66
|
+
return input.toLowerCase() === 'f' && Boolean(key.ctrl || key.meta || key.super);
|
|
67
|
+
}
|
|
61
68
|
export function clampVolume(value) {
|
|
62
69
|
return Math.min(100, Math.max(0, Math.round(value)));
|
|
63
70
|
}
|
package/dist/ui/ascii.js
CHANGED
|
@@ -64,6 +64,14 @@ function asciiGlyph(code) {
|
|
|
64
64
|
if (code === 0x25c9 || code === 0x25d2) {
|
|
65
65
|
return 'o'; // ◉ ◒
|
|
66
66
|
}
|
|
67
|
+
if (code === 0x2218)
|
|
68
|
+
return 'o'; // ring operator used by receiver patterns
|
|
69
|
+
if (code === 0x25c6 || code === 0x25c7)
|
|
70
|
+
return '*'; // decorative diamonds
|
|
71
|
+
if (code === 0x201c || code === 0x201d)
|
|
72
|
+
return '"';
|
|
73
|
+
if (code === 0x2018 || code === 0x2019)
|
|
74
|
+
return "'";
|
|
67
75
|
if (code === 0x25b2) {
|
|
68
76
|
return '^'; // ▲
|
|
69
77
|
}
|
|
@@ -105,8 +113,9 @@ function asciiGlyph(code) {
|
|
|
105
113
|
return '^';
|
|
106
114
|
if (code === 0x2193)
|
|
107
115
|
return 'v';
|
|
108
|
-
// Drop
|
|
109
|
-
|
|
116
|
+
// Drop invisible separators, but retain combining accents in station data.
|
|
117
|
+
// Width measurement already accounts for combining marks as zero columns.
|
|
118
|
+
if (code === 0x200b || code === 0xfeff) {
|
|
110
119
|
return '';
|
|
111
120
|
}
|
|
112
121
|
// Unknown Unicode is user/content data more often than decoration. Preserve
|
|
@@ -2,16 +2,19 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Text } from 'ink';
|
|
4
4
|
import { displayWidth, sliceDisplay, truncate } from '../format.js';
|
|
5
|
+
import { useDisplay } from '../display-context.js';
|
|
6
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
5
7
|
const edgePauseMs = 1000;
|
|
6
8
|
const stepMs = 180;
|
|
7
9
|
export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
|
|
10
|
+
const { ascii, reduceMotion: terminalReduceMotion } = useDisplay();
|
|
8
11
|
const safeWidth = Math.max(0, width);
|
|
9
12
|
const maxOffset = Math.max(0, displayWidth(text) - safeWidth);
|
|
10
|
-
const animated = active && !reduceMotion && maxOffset > 0;
|
|
13
|
+
const animated = active && !reduceMotion && !terminalReduceMotion && maxOffset > 0;
|
|
11
14
|
const [offset, setOffset] = useState(0);
|
|
12
15
|
useEffect(() => {
|
|
13
16
|
setOffset(0);
|
|
14
|
-
}, [active, reduceMotion, safeWidth, text]);
|
|
17
|
+
}, [active, reduceMotion, terminalReduceMotion, safeWidth, text]);
|
|
15
18
|
useEffect(() => {
|
|
16
19
|
if (!animated)
|
|
17
20
|
return;
|
|
@@ -24,5 +27,5 @@ export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
|
|
|
24
27
|
const visible = animated
|
|
25
28
|
? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth)
|
|
26
29
|
: truncate(text, safeWidth);
|
|
27
|
-
return _jsx(Text, { children: visible });
|
|
30
|
+
return _jsx(Text, { "aria-label": text, children: ascii ? toAsciiSafe(visible) : visible });
|
|
28
31
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { textMuted } from '../theme.js';
|
|
4
|
+
import { useDisplay } from '../display-context.js';
|
|
4
5
|
const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
|
|
5
6
|
function LogoSpectrum() {
|
|
6
|
-
|
|
7
|
+
const { ascii } = useDisplay();
|
|
8
|
+
return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, "aria-hidden": true, children: ascii ? '##' : '██' }, color))) }));
|
|
7
9
|
}
|
|
8
10
|
export function Logo({ compact = false, width = 80 } = {}) {
|
|
11
|
+
const { ascii } = useDisplay();
|
|
9
12
|
if (compact) {
|
|
10
13
|
const spectrumColumns = Math.max(1, width - 10);
|
|
11
14
|
const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns));
|
|
12
|
-
return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, children:
|
|
15
|
+
return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, "aria-hidden": true, children: ascii ? '#' : '█' }, color))] }));
|
|
13
16
|
}
|
|
14
17
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color: textMuted, children: "Live public radio from around the world" })] }));
|
|
15
18
|
}
|
|
@@ -2,10 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
export function Menu({ items, selected, keyFor, render }) {
|
|
4
4
|
const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
|
|
5
|
-
return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
|
|
5
|
+
return (_jsx(Box, { flexDirection: "column", "aria-role": "list", children: items.map((item, position) => (_jsx(Box, { "aria-role": "listitem", "aria-state": { selected: position === activeIndex }, children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
|
|
6
6
|
}
|
|
7
7
|
export function Pointer({ active }) {
|
|
8
|
-
return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
|
|
8
|
+
return _jsx(Text, { bold: active, "aria-hidden": true, children: active ? '> ' : ' ' });
|
|
9
9
|
}
|
|
10
10
|
function defaultMenuKey(item) {
|
|
11
11
|
if (typeof item === 'string' || typeof item === 'number') {
|
|
@@ -15,5 +15,5 @@ export function ScreenHeader({ title, width, theme, subtitle, right }) {
|
|
|
15
15
|
const rightText = right ? ` ${right}` : '';
|
|
16
16
|
const ruleWidth = Math.max(1, width - displayWidth(safeTitle) - displayWidth(rightText) - 2);
|
|
17
17
|
const rule = (ascii ? '-' : '─').repeat(ruleWidth);
|
|
18
|
-
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, "aria-hidden": true, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
|
|
19
19
|
}
|
|
@@ -1,15 +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 { stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
4
4
|
import { textMuted, themeAccent } from '../theme.js';
|
|
5
5
|
import { Menu, Pointer } from './Menu.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
7
|
import { useDisplay } from '../display-context.js';
|
|
8
8
|
import { AdaptiveMarquee } from './AdaptiveMarquee.js';
|
|
9
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
9
10
|
export function StationList({ stations, selected, theme, favorites, pageSize, width, emptyTitle = 'No stations found.', emptyHint = 'Try another view or clear active filters.', showCount = true }) {
|
|
10
|
-
const { reduceMotion } = useDisplay();
|
|
11
|
+
const { reduceMotion, ascii } = useDisplay();
|
|
12
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
11
13
|
if (stations.length === 0) {
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: emptyTitle }), _jsx(Text, { color: textMuted, children: emptyHint })] }));
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: a(emptyTitle) }), _jsx(Text, { color: textMuted, children: a(emptyHint) })] }));
|
|
13
15
|
}
|
|
14
16
|
const window = visibleWindow(stations, selected, pageSize);
|
|
15
17
|
const rowWidth = Math.max(42, width - 4);
|
|
@@ -19,14 +21,12 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
|
|
|
19
21
|
const favorite = favorites.has(`${station.provider}:${station.id}`);
|
|
20
22
|
if (width < 42) {
|
|
21
23
|
const compactNameWidth = Math.max(1, width - 2 - (favorite ? 2 : 0));
|
|
22
|
-
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow",
|
|
24
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", "aria-label": "Favorite", children: a(' ★') }) : null] }));
|
|
23
25
|
}
|
|
24
|
-
const
|
|
26
|
+
const stationNameWidth = favorite ? Math.max(1, nameWidth - 2) : nameWidth;
|
|
25
27
|
const titleWidth = nameWidth + 2;
|
|
26
|
-
const titleUsed = displayWidth(stationName) + (favorite ? 2 : 0);
|
|
27
|
-
const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
|
|
28
28
|
const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
|
|
29
29
|
const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
|
|
30
|
-
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children:
|
|
30
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", "aria-label": "Favorite", children: a(' ★') }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: a(truncate(active ? selectedMetadata : standardMetadata, metaWidth)) })] }));
|
|
31
31
|
} })] }));
|
|
32
32
|
}
|
|
@@ -21,7 +21,7 @@ export function TopTabs({ tabs, active, theme, width, backendLabel }) {
|
|
|
21
21
|
const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
|
|
22
22
|
const visibleTabsWidth = tabsWidth(visibleTabs);
|
|
23
23
|
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0));
|
|
24
|
-
return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children:
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, "aria-label": `RadioCLI. Tabs: ${tabs.map(tab => `${tab.label}${tab.screen === active ? ' (selected)' : ''}`).join(', ')}. Audio output: ${backendLabel}.`, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: ascii ? '.' : '…' })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
|
|
25
25
|
}
|
|
26
26
|
function fitTabs(tabs, active, maxWidth) {
|
|
27
27
|
const all = tabs.map(tab => ({ type: 'tab', tab }));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { updateAvailableForVersion } from '../../update-check.js';
|
|
4
|
+
import { displayWidth } from '../format.js';
|
|
5
|
+
import { appBackground, textDim, themeAccent } from '../theme.js';
|
|
6
|
+
export function availableUpdateLabel(currentVersion, updateCheck) {
|
|
7
|
+
return updateAvailableForVersion(updateCheck, currentVersion) && updateCheck?.latestVersion
|
|
8
|
+
? `v${updateCheck.latestVersion} available`
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
export function versionIndicatorWidth(currentVersion, updateCheck) {
|
|
12
|
+
const versionWidth = displayWidth(`v${currentVersion}`);
|
|
13
|
+
const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
|
|
14
|
+
return updateLabel ? displayWidth(updateLabel) + 3 + versionWidth : versionWidth;
|
|
15
|
+
}
|
|
16
|
+
export function VersionIndicator({ currentVersion, updateCheck, theme }) {
|
|
17
|
+
const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
|
|
18
|
+
return (_jsxs(Box, { flexShrink: 0, children: [updateLabel ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: appBackground, backgroundColor: themeAccent(theme), bold: true, children: ` ${updateLabel} ` }), _jsx(Text, { children: " " })] })) : null, _jsx(Text, { color: textDim, children: `v${currentVersion}` })] }));
|
|
19
|
+
}
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
|
+
import { resolveTerminalCapabilities } from '../platform/terminal.js';
|
|
2
3
|
import { appBackground, panelBackground } from './theme.js';
|
|
3
4
|
const opaqueBackgrounds = { app: appBackground, panel: panelBackground };
|
|
4
5
|
const transparentBackgrounds = { app: undefined, panel: undefined };
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
export function
|
|
8
|
-
|
|
6
|
+
// Sixteen-color palettes cannot safely reproduce our dark fills. Let those
|
|
7
|
+
// terminals keep their own background while Ink quantizes the foregrounds.
|
|
8
|
+
export function resolveDisplayMode(settings, env = process.env, evidence = {}) {
|
|
9
|
+
const terminal = resolveTerminalCapabilities(env, { ...settings, ...evidence });
|
|
10
|
+
const backgrounds = settings.transparentBackground || terminal.colorLevel < 2 ? transparentBackgrounds : opaqueBackgrounds;
|
|
11
|
+
return { ...backgrounds, ascii: !terminal.unicode, reduceMotion: terminal.reduceMotion, screenReader: terminal.screenReader, colorLevel: terminal.colorLevel };
|
|
9
12
|
}
|
|
10
|
-
|
|
11
|
-
// environment so light terminals keep their own background.
|
|
12
|
-
export function resolveDisplayMode(settings, env = process.env) {
|
|
13
|
-
const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds;
|
|
14
|
-
return { ...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion) };
|
|
15
|
-
}
|
|
16
|
-
const defaultMode = { ...opaqueBackgrounds, ascii: false, reduceMotion: false };
|
|
13
|
+
const defaultMode = resolveDisplayMode({}, {});
|
|
17
14
|
export const DisplayContext = createContext(defaultMode);
|
|
18
15
|
export function useDisplay() {
|
|
19
16
|
return useContext(DisplayContext);
|
package/dist/ui/help-content.js
CHANGED
|
@@ -20,7 +20,7 @@ export const keyHelpSections = [
|
|
|
20
20
|
{ keys: ', / . or F7/F9', description: 'Previous / next station' },
|
|
21
21
|
{ keys: '+ / -', description: 'Volume up / down' },
|
|
22
22
|
{ keys: 'm', description: 'Mute / unmute' },
|
|
23
|
-
{ keys: 'f', description: 'Favorite the station' },
|
|
23
|
+
{ keys: 'f / Ctrl+F', description: 'Favorite the station (Ctrl+F while typing in Search)' },
|
|
24
24
|
{ keys: 'O', description: 'Open station homepage' },
|
|
25
25
|
{ keys: 'y', description: 'Copy stream URL' },
|
|
26
26
|
{ keys: 's', description: 'Sleep timer (Now Playing)' },
|
|
@@ -70,7 +70,7 @@ export const commandHelp = [
|
|
|
70
70
|
{ name: 'ascii', args: '<on|off>', description: 'ASCII-safe display' },
|
|
71
71
|
{ name: 'motion', args: '<on|off>', description: 'Reduce motion' },
|
|
72
72
|
{ name: 'background', args: '<on|off>', description: 'Transparent background' },
|
|
73
|
-
{ name: 'airplay', description: 'Open AirPlay settings' },
|
|
73
|
+
{ name: 'airplay', description: 'Open AirPlay settings (macOS only)' },
|
|
74
74
|
{ name: 'learn', args: '<previous|play|next>', description: 'Learn a media key' },
|
|
75
75
|
{ name: 'keys', args: '[reset]', description: 'Show or reset learned media keys' },
|
|
76
76
|
{ name: 'favorite', description: 'Favorite the current station (alias :fav)' },
|
|
@@ -78,8 +78,9 @@ export const commandHelp = [
|
|
|
78
78
|
{ name: 'map', description: 'Open the world map' },
|
|
79
79
|
{ name: 'stats', description: 'Open listening stats' },
|
|
80
80
|
{ name: 'settings', description: 'Open settings' },
|
|
81
|
-
{ name: '
|
|
82
|
-
{ name: '
|
|
81
|
+
{ name: 'import', args: '<stream-url> [name]', description: 'Add a custom station and discover its details' },
|
|
82
|
+
{ name: 'export', args: '[backup.json]', description: 'Back up preferences and library' },
|
|
83
|
+
{ name: 'restore', args: '<backup.json>', description: 'Restore preferences and library' },
|
|
83
84
|
{ name: 'update', description: 'Show the install command for the latest release' },
|
|
84
85
|
{ name: 'stop', description: 'Stop playback' },
|
|
85
86
|
{ name: 'help', description: 'Open this help' }
|
|
@@ -103,7 +104,6 @@ export const commandNames = [
|
|
|
103
104
|
'airplay-code',
|
|
104
105
|
'airplay-settings',
|
|
105
106
|
'backup',
|
|
106
|
-
'restore',
|
|
107
107
|
...commandHelp.map(entry => entry.name)
|
|
108
108
|
].sort((a, b) => a.localeCompare(b));
|
|
109
109
|
// Complete a partial command name to the longest shared prefix of its matches.
|
package/dist/ui/layout.js
CHANGED
|
@@ -18,8 +18,11 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
|
|
|
18
18
|
const reservedFooterRows = Math.min(desiredFooterRows, Math.max(0, safeRows - topRows - 1));
|
|
19
19
|
const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
|
|
20
20
|
const mapMode = frameWidth >= 88 && contentRows >= 24 ? 'full' : 'compact';
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// Derive list windows from the live terminal height. Capping these values
|
|
22
|
+
// leaves the lower half of tall/full-screen terminals unused while the
|
|
23
|
+
// selection continues scrolling through rows that could already be visible.
|
|
24
|
+
const stationRows = Math.max(1, contentRows - 6);
|
|
25
|
+
const countryRows = Math.max(1, contentRows - 5);
|
|
23
26
|
return {
|
|
24
27
|
columns: safeColumns,
|
|
25
28
|
rows: safeRows,
|
package/dist/ui/page-footer.js
CHANGED
|
@@ -13,7 +13,7 @@ export function fullStatusFooterRows(screen, message, footerMessage, playbackSta
|
|
|
13
13
|
{ key: 'playback', text: footerMessage ?? playbackStatus ?? ' ' }
|
|
14
14
|
];
|
|
15
15
|
}
|
|
16
|
-
export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen }) {
|
|
16
|
+
export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, airPlaySupported = true, playbackBackend, screen, settingsPage = 'root' }) {
|
|
17
17
|
if (capturingTransportAction) {
|
|
18
18
|
return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
|
|
19
19
|
}
|
|
@@ -24,7 +24,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
24
24
|
return '↑/↓ move · Enter open · number jump · l location · : command';
|
|
25
25
|
}
|
|
26
26
|
if (screen === 'search' && editingSearch) {
|
|
27
|
-
return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
|
|
27
|
+
return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish';
|
|
28
28
|
}
|
|
29
29
|
if (screen === 'search') {
|
|
30
30
|
return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b Overview';
|
|
@@ -58,7 +58,9 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
58
58
|
return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b Overview';
|
|
59
59
|
}
|
|
60
60
|
if (screen === 'settings') {
|
|
61
|
-
return
|
|
61
|
+
return settingsPage === 'root'
|
|
62
|
+
? `↑/↓ choose · Enter open · o output${airPlaySupported ? ' · a AirPlay' : ''} · b Overview`
|
|
63
|
+
: '↑/↓ choose · Enter change · b Settings · shortcuts still work';
|
|
62
64
|
}
|
|
63
65
|
if (screen === 'airplay-settings') {
|
|
64
66
|
return canEnterAirPlayCode
|
package/dist/ui/screen-items.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const homeItems = [
|
|
2
2
|
{ screen: 'now-playing', label: 'Playing', detail: 'Receiver display and controls' },
|
|
3
|
-
{ screen: 'library', label: 'Library', detail: 'Favorites,
|
|
3
|
+
{ screen: 'library', label: 'Library', detail: 'Favorites, imported streams, recent stations' },
|
|
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' },
|
|
@@ -11,7 +11,9 @@ export const homeItems = [
|
|
|
11
11
|
];
|
|
12
12
|
export const settingsGroups = [
|
|
13
13
|
{
|
|
14
|
-
|
|
14
|
+
id: 'playback',
|
|
15
|
+
label: 'Playback & audio',
|
|
16
|
+
description: 'Output, volume, AirPlay, and startup behavior',
|
|
15
17
|
items: [
|
|
16
18
|
'Audio output',
|
|
17
19
|
'AirPlay receiver',
|
|
@@ -23,7 +25,9 @@ export const settingsGroups = [
|
|
|
23
25
|
]
|
|
24
26
|
},
|
|
25
27
|
{
|
|
26
|
-
|
|
28
|
+
id: 'appearance',
|
|
29
|
+
label: 'Appearance',
|
|
30
|
+
description: 'Colors, receiver style, motion, and terminal display',
|
|
27
31
|
items: [
|
|
28
32
|
'Cycle display color',
|
|
29
33
|
'Cycle receiver style',
|
|
@@ -34,22 +38,40 @@ export const settingsGroups = [
|
|
|
34
38
|
]
|
|
35
39
|
},
|
|
36
40
|
{
|
|
37
|
-
|
|
41
|
+
id: 'discovery',
|
|
42
|
+
label: 'Discovery & providers',
|
|
43
|
+
description: 'Location, station directories, privacy, and provider health',
|
|
38
44
|
items: [
|
|
39
45
|
'Toggle nearby location lookup',
|
|
40
46
|
'Toggle Radio Garden experimental adapter',
|
|
41
|
-
'Share favorite votes with Radio Browser'
|
|
47
|
+
'Share favorite votes with Radio Browser',
|
|
48
|
+
'Refresh provider health'
|
|
42
49
|
]
|
|
43
50
|
},
|
|
44
51
|
{
|
|
45
|
-
|
|
52
|
+
id: 'data',
|
|
53
|
+
label: 'Data & library',
|
|
54
|
+
description: 'Back up or restore preferences and saved stations',
|
|
46
55
|
items: [
|
|
47
56
|
'Export preferences and library',
|
|
48
|
-
'
|
|
57
|
+
'Restore preferences and library'
|
|
49
58
|
]
|
|
50
59
|
},
|
|
51
60
|
{
|
|
61
|
+
id: 'agent',
|
|
62
|
+
label: 'Agent control & MCP',
|
|
63
|
+
description: 'Local agent access and MCP playback behavior',
|
|
64
|
+
items: [
|
|
65
|
+
'Allow local agent control',
|
|
66
|
+
'Install or repair MCP integrations',
|
|
67
|
+
'Open TUI for agent playback',
|
|
68
|
+
'Show Now Playing for agent playback'
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'media-keys',
|
|
52
73
|
label: 'Media keys',
|
|
74
|
+
description: 'Teach RadioCLI your keyboard playback controls',
|
|
53
75
|
items: [
|
|
54
76
|
'Learn previous media key',
|
|
55
77
|
'Learn play/pause media key',
|
|
@@ -58,14 +80,23 @@ export const settingsGroups = [
|
|
|
58
80
|
]
|
|
59
81
|
},
|
|
60
82
|
{
|
|
61
|
-
|
|
83
|
+
id: 'updates',
|
|
84
|
+
label: 'Updates',
|
|
85
|
+
description: 'Automatic launch checks, version status, and installation',
|
|
62
86
|
items: [
|
|
63
|
-
'
|
|
87
|
+
'Automatically check for updates',
|
|
64
88
|
'Check for updates'
|
|
65
89
|
]
|
|
66
90
|
}
|
|
67
91
|
];
|
|
68
92
|
export const settingsItems = settingsGroups.flatMap(group => group.items);
|
|
69
|
-
export
|
|
70
|
-
|
|
93
|
+
export const settingsRootItems = settingsGroups.map(group => group.label);
|
|
94
|
+
export function settingsGroup(page) {
|
|
95
|
+
return settingsGroups.find(group => group.id === page);
|
|
96
|
+
}
|
|
97
|
+
export function settingsItemsForPage(page) {
|
|
98
|
+
return page === 'root' ? settingsRootItems : settingsGroup(page)?.items ?? [];
|
|
99
|
+
}
|
|
100
|
+
export function settingsPageForRootItem(item) {
|
|
101
|
+
return settingsGroups.find(group => group.label === item)?.id;
|
|
71
102
|
}
|
package/dist/ui/screen-meta.js
CHANGED
|
@@ -12,7 +12,7 @@ export const primaryScreens = [
|
|
|
12
12
|
export const screenMetadata = {
|
|
13
13
|
home: { tabLabel: 'Overview', title: 'Overview', description: 'Your listening at a glance' },
|
|
14
14
|
'now-playing': { tabLabel: 'Playing', title: 'Now playing', description: 'Receiver and playback controls' },
|
|
15
|
-
library: { tabLabel: 'Library', title: 'Library', description: 'Favorites,
|
|
15
|
+
library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, imported streams, and recents' },
|
|
16
16
|
explore: { tabLabel: 'Explore', title: 'Explore', description: 'Discover stations on the world map' },
|
|
17
17
|
search: { tabLabel: 'Search', title: 'Search', description: 'Find stations, genres, languages, or places' },
|
|
18
18
|
countries: { tabLabel: 'Countries', title: 'Countries', description: 'Browse stations by country' },
|
|
@@ -2,16 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
4
4
|
import { truncate } from '../format.js';
|
|
5
|
+
import { useDisplay } from '../display-context.js';
|
|
6
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
5
7
|
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
6
8
|
export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
|
|
9
|
+
const { ascii } = useDisplay();
|
|
10
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
7
11
|
const accent = themeAccent(theme);
|
|
8
12
|
const lineWidth = Math.max(24, width - 4);
|
|
9
13
|
const promptActive = isAirPlayCodePromptActive(playback);
|
|
10
14
|
const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
|
|
11
15
|
const masked = code.length > 0 ? '*'.repeat(code.length) : '';
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: a(truncate(receiverName, Math.max(8, lineWidth - 10))) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
|
|
13
17
|
? 'The receiver is asking for a code now.'
|
|
14
|
-
: 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children:
|
|
18
|
+
: 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: a('Enter submits · Backspace edits · Esc returns to AirPlay') })] })] }));
|
|
15
19
|
}
|
|
16
20
|
export function isAirPlayCodePromptActive(playback) {
|
|
17
21
|
return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
|
|
@@ -7,7 +7,11 @@ 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
|
+
import { useDisplay } from '../display-context.js';
|
|
11
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
10
12
|
export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width, height }) {
|
|
13
|
+
const { ascii } = useDisplay();
|
|
14
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
11
15
|
const accent = themeAccent(theme);
|
|
12
16
|
const availability = airPlayAvailability(backends, devices);
|
|
13
17
|
const selectedDevice = selectedAirPlayDevice(settings, devices);
|
|
@@ -16,11 +20,11 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
|
|
|
16
20
|
const lineWidth = Math.max(24, width - 4);
|
|
17
21
|
const preferredOutput = audioOutputLabel(settings.preferredBackend);
|
|
18
22
|
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 }),
|
|
23
|
+
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 }), a(' · Streaming: '), _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), a(' · Receivers: '), _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: a(truncate(availability.detail, lineWidth)) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: a(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: ", a(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) => {
|
|
20
24
|
const isSelected = device.id === settings.preferredAirPlayDevice;
|
|
21
25
|
const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
|
|
22
26
|
const selectedLabel = isSelected ? ' selected' : '';
|
|
23
27
|
const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
|
|
24
|
-
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null,
|
|
25
|
-
} })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(name) }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsx(Text, { color: textMuted, children: a(` · ${detail}`) })] }));
|
|
29
|
+
} })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers') })] })] }));
|
|
26
30
|
}
|
|
@@ -76,12 +76,13 @@ export function AlarmRingingScreen({ sessions, alarms = [], selected, snoozeMinu
|
|
|
76
76
|
const accent = themeAccent(theme);
|
|
77
77
|
const session = sessions[selected];
|
|
78
78
|
const label = session ? alarms.find(alarm => alarm.id === session.status.alarmId)?.label : undefined;
|
|
79
|
+
const starting = session?.status.state === 'starting';
|
|
79
80
|
const { ascii } = useDisplay();
|
|
80
81
|
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
81
82
|
const roomy = width >= 42 && height >= 12;
|
|
82
83
|
const clockText = session ? formatClock(new Date(session.status.scheduledAt)) : '';
|
|
83
84
|
const displayClock = roomy && !ascii ? fullwidthClock(clockText) : clockText;
|
|
84
|
-
return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Alarm ringing", subtitle: sessions.length > 1 ? `${selected + 1} of ${sessions.length} active alarms` : 'Wake radio · playing now', theme: theme, width: width }), session ? _jsxs(Box, { flexDirection: "column", alignItems: "center", width: width, children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(' ALARM ACTIVE ') }), _jsx(Box, { marginTop: roomy ? 1 : 0, children: _jsx(Text, { backgroundColor: roomy ? accent : undefined, color: roomy ? 'black' : accent, bold: true, children: a(roomy ? ` ${displayClock} ` : displayClock) }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: roomy ? 1 : 0, width: width, children: [_jsx(Text, { color: accent, bold: true, children: a(truncate(label ? `${session.status.stationName} · ${label}` : session.status.stationName, width)) }), height >= 12 ? _jsx(Text, { color: textMuted, children: a(truncate(
|
|
85
|
+
return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Alarm ringing", subtitle: sessions.length > 1 ? `${selected + 1} of ${sessions.length} active alarms` : starting ? 'Wake radio · preparing station' : 'Wake radio · playing now', theme: theme, width: width }), session ? _jsxs(Box, { flexDirection: "column", alignItems: "center", width: width, children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(starting ? ' ALARM STARTING ' : ' ALARM ACTIVE ') }), _jsx(Box, { marginTop: roomy ? 1 : 0, children: _jsx(Text, { backgroundColor: roomy ? accent : undefined, color: roomy ? 'black' : accent, bold: true, children: a(roomy ? ` ${displayClock} ` : displayClock) }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: roomy ? 1 : 0, width: width, children: [_jsx(Text, { color: accent, bold: true, children: a(truncate(label ? `${session.status.stationName} · ${label}` : session.status.stationName, width)) }), height >= 12 ? _jsx(Text, { color: textMuted, children: a(truncate(`${starting ? 'Preparing' : 'Started'} ${formatElapsed(session.status.startedAt)} ago · scheduled ${formatWhen(new Date(session.status.scheduledAt))}`, width)) }) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: width >= 58 ? 'row' : 'column', alignItems: "center", children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(starting ? ' ENTER WAIT FOR STATION ' : ' ENTER KEEP PLAYING ') }), width >= 58 ? _jsx(Text, { children: " " }) : null, _jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(` SPACE SNOOZE ${snoozeMinutes} MIN `) })] }), height >= 14 ? _jsx(Text, { color: textMuted, children: a(starting ? 'Space snoozes before playback · b return' : 'Enter opens Playing · Space stops the radio until snooze ends · b return') }) : null, sessions.length > 1 && height >= 15 ? _jsx(Text, { color: textMuted, children: a(`↑/↓ choose active alarm · ${selected + 1}/${sessions.length}`) }) : null] }) : _jsx(Text, { color: textMuted, children: "The alarm session ended. Press b to return." })] });
|
|
85
86
|
}
|
|
86
87
|
function editorRows(draft) {
|
|
87
88
|
const next = draftNextOccurrence(draft);
|
|
@@ -4,16 +4,19 @@ 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 { displayWidth
|
|
8
|
-
|
|
7
|
+
import { displayWidth } from '../format.js';
|
|
8
|
+
import { AdaptiveMarquee } from '../components/AdaptiveMarquee.js';
|
|
9
|
+
export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width, reduceMotion = false }) {
|
|
9
10
|
const window = visibleWindow(countries, selected, pageSize);
|
|
10
11
|
const rowWidth = Math.max(24, width - 2);
|
|
11
12
|
const countWidth = Math.max(12, Math.min(22, Math.max(...countries.map(country => `${country.stationCount.toLocaleString()} stations`.length), 12)));
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// Keep the metadata column fixed while the viewport scrolls. Forty-two cells
|
|
14
|
+
// accommodates long directory names such as Lao People's Democratic Republic
|
|
15
|
+
// plus its country code, while narrow terminals still receive a bounded row.
|
|
16
|
+
const countryWidth = Math.min(42, Math.max(8, rowWidth - countWidth - 4));
|
|
14
17
|
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
18
|
const code = ` (${country.code})`;
|
|
16
19
|
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:
|
|
20
|
+
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: _jsx(AdaptiveMarquee, { text: country.name, width: nameWidth, active: active, reduceMotion: reduceMotion }) }), _jsx(Text, { color: textMuted, children: code })] }), _jsx(Box, { width: 2 }), _jsx(Box, { width: countWidth, children: _jsxs(Text, { color: textMuted, children: [country.stationCount.toLocaleString(), " stations"] }) })] }));
|
|
18
21
|
} })] })) : null] }));
|
|
19
22
|
}
|