@ciphore/radiocli 0.2.3 → 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 +77 -0
- package/CONTRIBUTING.md +36 -6
- package/README.md +54 -10
- package/dist/agent/headless-host.js +39 -17
- package/dist/agent/launcher.js +6 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/service.js +3 -3
- package/dist/agent/session.js +13 -29
- package/dist/alarms/active-session.js +9 -12
- 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 +56 -25
- 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 +103 -38
- 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 +203 -33
- package/dist/providers/cache.js +4 -26
- package/dist/providers/radio-browser.js +152 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +79 -151
- package/dist/storage/store.js +105 -52
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +33 -24
- package/dist/ui/App.js +173 -86
- package/dist/ui/AppContent.js +3 -3
- 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 +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- 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 +3 -3
- package/dist/ui/screen-items.js +2 -2
- 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/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +5 -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 +14 -7
- 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 +36 -21
- package/dist/ui/use-app-input.js +42 -22
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/update-check.js +8 -17
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { withExternalResponse } from '../platform/network.js';
|
|
2
|
+
import { safeExternalHttpUrl, sanitizeTerminalText } from '../safety.js';
|
|
3
|
+
import { stationFromUrl } from '../playlists/playlist.js';
|
|
4
|
+
class DefiniteStreamError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Build a custom station and enrich it from bounded HTTP/ICY response headers.
|
|
8
|
+
* The submitted URL is intentionally retained because a followed redirect may
|
|
9
|
+
* contain a short-lived playback token.
|
|
10
|
+
*/
|
|
11
|
+
export async function importStreamUrl(input, requestedName, options = {}) {
|
|
12
|
+
const url = validatedStreamUrl(input);
|
|
13
|
+
const explicitName = sanitizeTerminalText(requestedName);
|
|
14
|
+
try {
|
|
15
|
+
let metadata = await probeStreamHeaders(url, options);
|
|
16
|
+
const listing = await probeIHeartListing(url, options).catch(() => undefined);
|
|
17
|
+
if (listing)
|
|
18
|
+
metadata = { ...metadata, ...listing };
|
|
19
|
+
const name = explicitName ?? metadata.name ?? fallbackStationName(url);
|
|
20
|
+
return {
|
|
21
|
+
station: {
|
|
22
|
+
...stationFromUrl(url, name),
|
|
23
|
+
...(metadata.homepage ? { homepage: metadata.homepage } : {}),
|
|
24
|
+
...(metadata.codec ? { codec: metadata.codec } : {}),
|
|
25
|
+
...(metadata.bitrate ? { bitrate: metadata.bitrate } : {}),
|
|
26
|
+
...(metadata.hls !== undefined ? { hls: metadata.hls } : {})
|
|
27
|
+
},
|
|
28
|
+
identified: Boolean(metadata.name),
|
|
29
|
+
...(!metadata.name && !explicitName
|
|
30
|
+
? { warning: `The stream did not publish a station name; saved as ${name}.` }
|
|
31
|
+
: {})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof DefiniteStreamError)
|
|
36
|
+
throw error;
|
|
37
|
+
const name = explicitName ?? fallbackStationName(url);
|
|
38
|
+
return {
|
|
39
|
+
station: stationFromUrl(url, name),
|
|
40
|
+
identified: false,
|
|
41
|
+
warning: `Could not read stream metadata; saved as ${name}.`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function probeStreamHeaders(url, options) {
|
|
46
|
+
return withExternalResponse(url, {
|
|
47
|
+
timeoutMs: options.timeoutMs ?? 7_000,
|
|
48
|
+
fetchImpl: options.fetchImpl,
|
|
49
|
+
init: {
|
|
50
|
+
method: 'GET',
|
|
51
|
+
redirect: 'follow',
|
|
52
|
+
headers: {
|
|
53
|
+
accept: 'audio/*, application/ogg, application/vnd.apple.mpegurl, application/x-mpegurl, */*;q=0.1',
|
|
54
|
+
'icy-metadata': '1'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}, response => {
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new DefiniteStreamError(`The stream returned HTTP ${response.status}.`);
|
|
60
|
+
}
|
|
61
|
+
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
62
|
+
if (contentType === 'text/html' || contentType === 'application/json') {
|
|
63
|
+
throw new DefiniteStreamError('That URL points to a webpage or API response, not a direct radio stream.');
|
|
64
|
+
}
|
|
65
|
+
const icyName = sanitizeTerminalText(response.headers.get('icy-name'));
|
|
66
|
+
const icyDescription = sanitizeTerminalText(response.headers.get('icy-description'));
|
|
67
|
+
const homepage = safeOptionalHttpUrl(response.headers.get('icy-url'));
|
|
68
|
+
const bitrate = parseBitrate(response.headers.get('icy-br'), response.headers.get('icy-audio-info'));
|
|
69
|
+
const codec = codecFromContentType(contentType);
|
|
70
|
+
const hls = isHls(contentType, url);
|
|
71
|
+
return {
|
|
72
|
+
...(icyName || icyDescription ? { name: icyName ?? icyDescription } : {}),
|
|
73
|
+
...(homepage ? { homepage } : {}),
|
|
74
|
+
...(codec ? { codec } : {}),
|
|
75
|
+
...(bitrate ? { bitrate } : {}),
|
|
76
|
+
...(hls ? { hls: true } : {})
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** iHeart stream headers are sometimes unnamed, but their stable numeric stream
|
|
81
|
+
* id resolves to a canonical public station path without downloading the page. */
|
|
82
|
+
async function probeIHeartListing(url, options) {
|
|
83
|
+
const parsed = new URL(url);
|
|
84
|
+
if (!/(?:^|\.)ihrhls\.com$/i.test(parsed.hostname))
|
|
85
|
+
return undefined;
|
|
86
|
+
const stationId = /\/zc(\d+)(?:\/|$)/i.exec(parsed.pathname)?.[1]
|
|
87
|
+
?? /\/(\d+)_icy(?:\/|$)/i.exec(parsed.pathname)?.[1];
|
|
88
|
+
if (!stationId)
|
|
89
|
+
return undefined;
|
|
90
|
+
return withExternalResponse(`https://www.iheart.com/live/${stationId}/`, {
|
|
91
|
+
timeoutMs: Math.min(options.timeoutMs ?? 7_000, 4_000),
|
|
92
|
+
fetchImpl: options.fetchImpl,
|
|
93
|
+
init: { method: 'HEAD', redirect: 'manual' }
|
|
94
|
+
}, response => {
|
|
95
|
+
const location = response.headers.get('location');
|
|
96
|
+
const slug = location && new RegExp(`^/live/(.+)-${stationId}/?$`, 'i').exec(location)?.[1];
|
|
97
|
+
if (!slug)
|
|
98
|
+
return undefined;
|
|
99
|
+
const name = stationNameFromSlug(slug);
|
|
100
|
+
return name ? { name, homepage: `https://www.iheart.com${location}` } : undefined;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function validatedStreamUrl(input) {
|
|
104
|
+
const cleaned = safeExternalHttpUrl(input);
|
|
105
|
+
if (!cleaned)
|
|
106
|
+
throw new Error('Import requires a direct HTTP(S) stream URL.');
|
|
107
|
+
const parsed = new URL(cleaned);
|
|
108
|
+
if (parsed.username || parsed.password)
|
|
109
|
+
throw new Error('Stream URLs containing credentials are not supported.');
|
|
110
|
+
return cleaned;
|
|
111
|
+
}
|
|
112
|
+
function safeOptionalHttpUrl(input) {
|
|
113
|
+
return input ? safeExternalHttpUrl(input) ?? undefined : undefined;
|
|
114
|
+
}
|
|
115
|
+
function parseBitrate(icyBitrate, audioInfo) {
|
|
116
|
+
const raw = icyBitrate?.match(/\d+/)?.[0] ?? audioInfo?.match(/(?:^|;)\s*bitrate=(\d+)/i)?.[1];
|
|
117
|
+
const bitrate = Number(raw);
|
|
118
|
+
return Number.isFinite(bitrate) && bitrate > 0 ? bitrate : undefined;
|
|
119
|
+
}
|
|
120
|
+
function codecFromContentType(contentType) {
|
|
121
|
+
if (!contentType)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (contentType === 'audio/mpeg' || contentType === 'audio/mp3')
|
|
124
|
+
return 'MP3';
|
|
125
|
+
if (contentType === 'audio/aac' || contentType === 'audio/aacp')
|
|
126
|
+
return 'AAC';
|
|
127
|
+
if (contentType === 'audio/ogg' || contentType === 'application/ogg')
|
|
128
|
+
return 'OGG';
|
|
129
|
+
if (contentType === 'audio/flac' || contentType === 'audio/x-flac')
|
|
130
|
+
return 'FLAC';
|
|
131
|
+
if (contentType.includes('mpegurl'))
|
|
132
|
+
return 'HLS';
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
function isHls(contentType, url) {
|
|
136
|
+
return Boolean(contentType?.includes('mpegurl') || /\.m3u8(?:$|[?#])/i.test(url));
|
|
137
|
+
}
|
|
138
|
+
function fallbackStationName(url) {
|
|
139
|
+
const parsed = new URL(url);
|
|
140
|
+
const segment = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '')
|
|
141
|
+
.replace(/\.(?:aac|flac|m3u8?|mp3|ogg|opus)$/i, '')
|
|
142
|
+
.replace(/[-_]+/g, ' ')
|
|
143
|
+
.trim();
|
|
144
|
+
return sanitizeTerminalText(segment) ?? parsed.hostname.replace(/^www\./i, '');
|
|
145
|
+
}
|
|
146
|
+
function stationNameFromSlug(slug) {
|
|
147
|
+
const tokens = slug.split('-').filter(Boolean);
|
|
148
|
+
const final = tokens.at(-1);
|
|
149
|
+
const previous = tokens.at(-2);
|
|
150
|
+
if (final && previous && /^\d$/.test(final) && /^\d{2,3}$/.test(previous)) {
|
|
151
|
+
tokens.splice(-2, 2, `${previous}.${final}`);
|
|
152
|
+
}
|
|
153
|
+
return sanitizeTerminalText(tokens.map(token => {
|
|
154
|
+
if (/^(?:am|fm|hd\d*)$/i.test(token))
|
|
155
|
+
return token.toUpperCase();
|
|
156
|
+
if (/^jamn$/i.test(token))
|
|
157
|
+
return "JAM'N";
|
|
158
|
+
if (/^\d{3,4}$/.test(token) && Number(token) >= 881 && Number(token) <= 1079) {
|
|
159
|
+
return `${token.slice(0, -1)}.${token.slice(-1)}`;
|
|
160
|
+
}
|
|
161
|
+
return token.charAt(0).toUpperCase() + token.slice(1);
|
|
162
|
+
}).join(' '));
|
|
163
|
+
}
|
|
@@ -23,9 +23,9 @@ export function AdaptiveContent(props) {
|
|
|
23
23
|
return _jsx(AdaptiveContentBody, { ...props });
|
|
24
24
|
}
|
|
25
25
|
function AdaptiveContentBody(props) {
|
|
26
|
-
const { mode, screen, settingsPage = 'root', selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, exploreCursor, library, diagnostics, backends, updateCheck, appVersion, favoriteKeys, stationTitle, } = props;
|
|
26
|
+
const { mode, screen, settingsPage = 'root', selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, exploreCursor, library, diagnostics, backends, updateCheck, appVersion, favoriteKeys, stationTitle, airPlaySupported = true, } = props;
|
|
27
27
|
const accent = themeAccent(theme);
|
|
28
|
-
const { ascii } = useDisplay();
|
|
28
|
+
const { ascii, reduceMotion } = useDisplay();
|
|
29
29
|
const { bodyRows } = adaptiveFrameMetrics(mode, height);
|
|
30
30
|
const title = screenTitle(screen);
|
|
31
31
|
const status = adaptiveStatus(props);
|
|
@@ -33,10 +33,10 @@ function AdaptiveContentBody(props) {
|
|
|
33
33
|
return (_jsx(AdaptiveHome, { mode: mode, selected: selected, height: height, width: width, theme: theme, library: library }));
|
|
34
34
|
}
|
|
35
35
|
if (screen === 'now-playing') {
|
|
36
|
-
return (_jsx(AdaptiveNowPlaying, { mode: mode, station: playingStation, playback: playback, metadata: nowPlaying, width: width, height: height, ascii: ascii, theme: theme, receiverStyle: library.settings.receiverStyle, reduceMotion:
|
|
36
|
+
return (_jsx(AdaptiveNowPlaying, { mode: mode, station: playingStation, playback: playback, metadata: nowPlaying, width: width, height: height, ascii: ascii, theme: theme, receiverStyle: library.settings.receiverStyle, reduceMotion: reduceMotion }));
|
|
37
37
|
}
|
|
38
38
|
if (screen === 'explore') {
|
|
39
|
-
return (_jsx(AdaptiveExplore, { mode: mode, width: width, height: height, theme: theme, stations: stations, selected: selected, favorites: favoriteKeys, cursor: exploreCursor, loading: loadingStations, error: props.stationError, ascii: ascii, reduceMotion:
|
|
39
|
+
return (_jsx(AdaptiveExplore, { mode: mode, width: width, height: height, theme: theme, stations: stations, selected: selected, favorites: favoriteKeys, cursor: exploreCursor, loading: loadingStations, error: props.stationError, ascii: ascii, reduceMotion: reduceMotion }));
|
|
40
40
|
}
|
|
41
41
|
if (screen === 'search') {
|
|
42
42
|
const rows = adaptiveRows({
|
|
@@ -54,7 +54,8 @@ function AdaptiveContentBody(props) {
|
|
|
54
54
|
selected,
|
|
55
55
|
width,
|
|
56
56
|
mode,
|
|
57
|
-
ascii
|
|
57
|
+
ascii,
|
|
58
|
+
airPlaySupported
|
|
58
59
|
});
|
|
59
60
|
const empty = adaptiveEmptyState({
|
|
60
61
|
screen,
|
|
@@ -67,7 +68,7 @@ function AdaptiveContentBody(props) {
|
|
|
67
68
|
stationTitle,
|
|
68
69
|
stationError: props.stationError
|
|
69
70
|
});
|
|
70
|
-
return (_jsx(AdaptiveSearch, { mode: mode, query: searchQuery, editing: editingSearch, loading: loadingStations, rows: rows, empty: empty, selected: selected, height: height, width: width, theme: theme, ascii: ascii, reduceMotion:
|
|
71
|
+
return (_jsx(AdaptiveSearch, { mode: mode, query: searchQuery, editing: editingSearch, loading: loadingStations, rows: rows, empty: empty, selected: selected, height: height, width: width, theme: theme, ascii: ascii, reduceMotion: reduceMotion }));
|
|
71
72
|
}
|
|
72
73
|
if (screen === 'stats') {
|
|
73
74
|
const stats = computeListeningStats(library.activity.sessions);
|
|
@@ -86,7 +87,7 @@ function AdaptiveContentBody(props) {
|
|
|
86
87
|
}
|
|
87
88
|
if (screen === 'airplay-code') {
|
|
88
89
|
const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.';
|
|
89
|
-
return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: bodyRows > 0 ? _jsx(Text, { color: airPlayCode ? accent : textMuted, children: truncate(prompt, width) }) : null }));
|
|
90
|
+
return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: bodyRows > 0 ? _jsx(Text, { color: airPlayCode ? accent : textMuted, children: ascii ? toAsciiSafe(truncate(prompt, width)) : truncate(prompt, width) }) : null }));
|
|
90
91
|
}
|
|
91
92
|
const rows = adaptiveRows({
|
|
92
93
|
screen,
|
|
@@ -103,7 +104,8 @@ function AdaptiveContentBody(props) {
|
|
|
103
104
|
selected,
|
|
104
105
|
width,
|
|
105
106
|
mode,
|
|
106
|
-
ascii
|
|
107
|
+
ascii,
|
|
108
|
+
airPlaySupported
|
|
107
109
|
});
|
|
108
110
|
const empty = adaptiveEmptyState({
|
|
109
111
|
screen,
|
|
@@ -116,11 +118,12 @@ function AdaptiveContentBody(props) {
|
|
|
116
118
|
stationTitle,
|
|
117
119
|
stationError: props.stationError
|
|
118
120
|
});
|
|
119
|
-
return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: bodyRows, width: width, theme: theme, reduceMotion:
|
|
121
|
+
return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: bodyRows, width: width, theme: theme, reduceMotion: reduceMotion })) : (_jsx(StaticRows, { rows: empty.slice(0, bodyRows), width: width, theme: theme })) }));
|
|
120
122
|
}
|
|
121
123
|
function AdaptiveExplore({ mode, width, height, theme, stations, selected, favorites, cursor, loading, error, ascii, reduceMotion }) {
|
|
122
124
|
const accent = themeAccent(theme);
|
|
123
125
|
const { headerRows, headerGap, bodyRows } = adaptiveExploreFrameMetrics(mode, height);
|
|
126
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
124
127
|
const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows));
|
|
125
128
|
const marker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
|
|
126
129
|
const map = React.useMemo(() => buildCosmoWorldMap(layout.mapColumns, layout.mapRows, marker), [layout.mapColumns, layout.mapRows, marker]);
|
|
@@ -134,10 +137,10 @@ function AdaptiveExplore({ mode, width, height, theme, stations, selected, favor
|
|
|
134
137
|
? [{ key: 'error', label: error }]
|
|
135
138
|
: [{ key: 'empty', label: 'No stations near this point.' }];
|
|
136
139
|
const list = (_jsx(AdaptiveList, { rows: stationRows.length > 0 ? stationRows : emptyRows, selected: stationRows.length > 0 ? selected : 0, pageSize: layout.listRows, width: layout.listWidth, theme: theme, reduceMotion: reduceMotion }));
|
|
137
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: truncate(title, width) }), headerRows > 1 ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, headerGap ? _jsx(Box, { height: headerGap, flexShrink: 0 }) : null, bodyRows > 0 ? layout.split ? (_jsxs(Box, { flexDirection: "row", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { width: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", width: layout.listWidth, height: layout.listRows, overflow: "hidden", children: list })] })) : (_jsxs(Box, { flexDirection: "column", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { height: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: layout.listRows, width: layout.listWidth, overflow: "hidden", children: list })] })) : null] }));
|
|
140
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, "aria-label": title, children: a(truncate(title, width)) }), headerRows > 1 ? _jsx(Text, { color: textMuted, "aria-label": status, children: a(truncate(status, width)) }) : null, headerGap ? _jsx(Box, { height: headerGap, flexShrink: 0 }) : null, bodyRows > 0 ? layout.split ? (_jsxs(Box, { flexDirection: "row", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { width: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", width: layout.listWidth, height: layout.listRows, overflow: "hidden", children: list })] })) : (_jsxs(Box, { flexDirection: "column", height: bodyRows, width: width, overflow: "hidden", children: [_jsx(AdaptiveCosmoMap, { rows: map, width: layout.mapAreaWidth, height: layout.mapRows, offsetX: layout.mapOffsetX, theme: theme, ascii: ascii }), layout.gap ? _jsx(Box, { height: layout.gap, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: layout.listRows, width: layout.listWidth, overflow: "hidden", children: list })] })) : null] }));
|
|
138
141
|
}
|
|
139
142
|
function AdaptiveCosmoMap({ rows, width, height, offsetX, theme, ascii }) {
|
|
140
|
-
return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", flexShrink: 0, children: rows.map((row, rowIndex) => {
|
|
143
|
+
return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", flexShrink: 0, "aria-hidden": true, children: rows.map((row, rowIndex) => {
|
|
141
144
|
const chunks = [];
|
|
142
145
|
for (const cell of row.cells) {
|
|
143
146
|
const previous = chunks.at(-1);
|
|
@@ -166,11 +169,12 @@ function adaptiveMapColor(kind, theme) {
|
|
|
166
169
|
function AdaptiveFrame({ title, status, mode, height, width, theme, children }) {
|
|
167
170
|
const accent = themeAccent(theme);
|
|
168
171
|
const { ascii } = useDisplay();
|
|
172
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
169
173
|
const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI ';
|
|
170
174
|
const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width);
|
|
171
175
|
const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1);
|
|
172
176
|
const { bodyRows, gapRows } = adaptiveFrameMetrics(mode, height);
|
|
173
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsxs(Text, { color: accent, bold: true, children: [titleText, _jsx(Text, { color: textDim, children: ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : '' })] }), mode === 'compact' && height >= 4 ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", flexShrink: 0, children: children }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
177
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsxs(Text, { color: accent, bold: true, "aria-label": `${titlePrefix}${title}${status ? `. ${status}` : ''}`, children: [a(titleText), _jsx(Text, { color: textDim, "aria-hidden": true, children: ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : '' })] }), mode === 'compact' && height >= 4 ? _jsx(Text, { color: textMuted, "aria-label": status, children: a(truncate(status, width)) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", flexShrink: 0, children: children }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
174
178
|
}
|
|
175
179
|
function adaptiveFrameMetrics(mode, height) {
|
|
176
180
|
const headerRows = mode === 'compact' && height >= 4 ? 2 : 1;
|
|
@@ -182,6 +186,8 @@ function adaptiveFrameMetrics(mode, height) {
|
|
|
182
186
|
}
|
|
183
187
|
function AdaptiveHome({ mode, selected, height, width, theme, library }) {
|
|
184
188
|
const gapRows = height >= 5 ? 1 : 0;
|
|
189
|
+
const { ascii } = useDisplay();
|
|
190
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
185
191
|
const showSummary = mode === 'compact' && height >= 10;
|
|
186
192
|
const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0));
|
|
187
193
|
const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1);
|
|
@@ -191,8 +197,8 @@ function AdaptiveHome({ mode, selected, height, width, theme, library }) {
|
|
|
191
197
|
const absoluteIndex = window.start + offset;
|
|
192
198
|
const active = absoluteIndex === selectedIndex;
|
|
193
199
|
const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : '';
|
|
194
|
-
return (_jsxs(Text, { color: active ? accent : undefined, bold: active, children: [active ? '> ' : ' ', absoluteIndex + 1, " ", truncate(`${item.label}${detail}`, Math.max(1, width - 4))] }, item.screen));
|
|
195
|
-
}) }), showSummary ? (_jsx(Text, { color: textMuted, children: truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
200
|
+
return (_jsxs(Text, { color: active ? accent : undefined, bold: active, "aria-label": `${active ? 'Selected: ' : ''}${absoluteIndex + 1}. ${item.label}. ${item.detail}`, children: [active ? '> ' : ' ', absoluteIndex + 1, " ", a(truncate(`${item.label}${detail}`, Math.max(1, width - 4)))] }, item.screen));
|
|
201
|
+
}) }), showSummary ? (_jsx(Text, { color: textMuted, children: a(truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width)) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
196
202
|
}
|
|
197
203
|
function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion }) {
|
|
198
204
|
const selectedIndex = Math.min(Math.max(selected, 0), Math.max(0, rows.length - 1));
|
|
@@ -205,35 +211,37 @@ function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion })
|
|
|
205
211
|
const detail = row.detail ? `${row.separator ?? ' · '}${row.detail}` : '';
|
|
206
212
|
const text = `${row.label}${detail}`;
|
|
207
213
|
const favoriteWidth = row.favoriteGlyph ? 2 : 0;
|
|
208
|
-
return (_jsxs(Text, { color: active ? accent : row.heading ? textMuted : undefined, bold: active || row.heading, children: [prefix, _jsx(AdaptiveMarquee, { text: text, width: Math.max(0, width - 2 - favoriteWidth), active: active && Boolean(row.marquee), reduceMotion: reduceMotion }), row.favoriteGlyph ? _jsxs(Text, { color: "yellow", children: [" ", row.favoriteGlyph] }) : null] }, row.key));
|
|
214
|
+
return (_jsxs(Text, { color: active ? accent : row.heading ? textMuted : undefined, bold: active || row.heading, "aria-label": `${active ? 'Selected: ' : ''}${text}${row.favoriteGlyph ? '. Favorite' : ''}`, children: [prefix, _jsx(AdaptiveMarquee, { text: text, width: Math.max(0, width - 2 - favoriteWidth), active: active && Boolean(row.marquee), reduceMotion: reduceMotion }), row.favoriteGlyph ? _jsxs(Text, { color: "yellow", children: [" ", row.favoriteGlyph] }) : null] }, row.key));
|
|
209
215
|
}) }));
|
|
210
216
|
}
|
|
211
217
|
function StaticRows({ rows, width, theme }) {
|
|
212
218
|
const accent = themeAccent(theme);
|
|
213
|
-
|
|
219
|
+
const { ascii } = useDisplay();
|
|
220
|
+
return (_jsx(Box, { flexDirection: "column", children: rows.map(row => (_jsxs(Text, { color: row.heading ? textMuted : undefined, bold: row.heading, "aria-label": row.detail ? `${row.label}: ${row.detail}` : row.label, children: [ascii ? toAsciiSafe(truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width)) : truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width), row.key === 'activity' ? _jsx(Text, { color: accent }) : null] }, row.key))) }));
|
|
214
221
|
}
|
|
215
222
|
function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height, ascii, theme, receiverStyle, reduceMotion }) {
|
|
216
223
|
const pulse = useReceiverPulse();
|
|
224
|
+
const { screenReader } = useDisplay();
|
|
217
225
|
const accent = themeAccent(theme);
|
|
218
226
|
const stationName = station?.name ?? 'No station tuned';
|
|
219
|
-
const showMetadata = mode === 'compact' && height >= 9
|
|
227
|
+
const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9);
|
|
220
228
|
const headerRows = mode === 'compact' ? 2 : 1;
|
|
221
229
|
const metadataRows = showMetadata ? 1 : 0;
|
|
222
230
|
const gapRows = height >= 5 ? 1 : 0;
|
|
223
231
|
const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2);
|
|
224
232
|
const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width);
|
|
225
|
-
const visualRows = buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
|
|
233
|
+
const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
|
|
226
234
|
const header = mode === 'micro'
|
|
227
235
|
? `${stationName} · ${playback.state}`
|
|
228
236
|
: 'RADIOCLI Now playing';
|
|
229
237
|
const status = `${stationName} ${playback.state}${playback.muted ? ' · muted' : ` · vol ${playback.volume}`}`;
|
|
230
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro'
|
|
238
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, "aria-label": mode === 'micro' ? `Now playing. Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}` : undefined, children: mode === 'micro'
|
|
231
239
|
? _jsx(AdaptiveMarquee, { text: header, width: width, active: true, reduceMotion: reduceMotion })
|
|
232
|
-
: truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
240
|
+
: truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, "aria-label": `Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, !screenReader ? _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", "aria-hidden": true, children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
233
241
|
? renderAdaptiveSegments(row.segments, ascii)
|
|
234
242
|
: ascii
|
|
235
243
|
? toAsciiSafe(row.text)
|
|
236
|
-
: row.text }, index))) }), showMetadata ? (_jsx(Text, { color: accent, children: _jsx(AdaptiveMarquee, { text: metadata?.title ?? '', width: width, active: true, reduceMotion: reduceMotion }) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
244
|
+
: row.text }, index))) }) : null, showMetadata ? (_jsx(Text, { color: accent, "aria-label": `Track: ${metadata?.title ?? ''}`, children: _jsx(AdaptiveMarquee, { text: metadata?.title ?? '', width: width, active: true, reduceMotion: reduceMotion }) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
237
245
|
}
|
|
238
246
|
function renderAdaptiveSegments(segments, ascii) {
|
|
239
247
|
let offset = 0;
|
|
@@ -246,12 +254,13 @@ function renderAdaptiveSegments(segments, ascii) {
|
|
|
246
254
|
function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii, reduceMotion }) {
|
|
247
255
|
const accent = themeAccent(theme);
|
|
248
256
|
const { panel: panelBackground } = useDisplay();
|
|
257
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
249
258
|
const fieldText = query || 'station, genre, or place';
|
|
250
259
|
const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ ';
|
|
251
260
|
const fieldRows = height >= 4 ? 3 : 1;
|
|
252
261
|
const gapRows = height >= 6 ? 1 : 0;
|
|
253
262
|
const listRows = Math.max(0, height - 1 - fieldRows - gapRows * 2);
|
|
254
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro' ? 'SEARCH' : 'RADIOCLI Search' }), fieldRows === 3 ? (_jsxs(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing || loading ? accent : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, height: 3, flexShrink: 0, children: [_jsx(Text, { color: editing || loading ? accent : textMuted, children: prefix }), _jsx(Text, { color: query ? accent : textMuted, children: truncate(fieldText, Math.max(1, width - 5)) })] })) : (_jsx(Text, { color: query ? accent : textMuted, children: truncate(`[${prefix}${fieldText}]`, width) })), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: listRows, overflow: "hidden", flexShrink: 0, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: listRows, width: width, theme: theme, reduceMotion: reduceMotion })) : (_jsx(StaticRows, { rows: empty.slice(0, listRows), width: width, theme: theme })) }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
263
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro' ? 'SEARCH' : 'RADIOCLI Search' }), fieldRows === 3 ? (_jsxs(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing || loading ? accent : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, height: 3, flexShrink: 0, children: [_jsx(Text, { color: editing || loading ? accent : textMuted, children: a(prefix) }), _jsx(Text, { color: query ? accent : textMuted, children: a(truncate(fieldText, Math.max(1, width - 5))) })] })) : (_jsx(Text, { color: query ? accent : textMuted, children: a(truncate(`[${prefix}${fieldText}]`, width)) })), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: listRows, overflow: "hidden", flexShrink: 0, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: listRows, width: width, theme: theme, reduceMotion: reduceMotion })) : (_jsx(StaticRows, { rows: empty.slice(0, listRows), width: width, theme: theme })) }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
|
|
255
264
|
}
|
|
256
265
|
function adaptiveRows(input) {
|
|
257
266
|
const { screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii } = input;
|
|
@@ -265,11 +274,11 @@ function adaptiveRows(input) {
|
|
|
265
274
|
}
|
|
266
275
|
if (screen === 'settings') {
|
|
267
276
|
const pageItems = settingsItemsForPage(input.settingsPage);
|
|
268
|
-
const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion));
|
|
277
|
+
const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
|
|
269
278
|
const labelWidth = pairedColumnWidth(labels, width, mode);
|
|
270
279
|
return pageItems.map((item, index) => ({
|
|
271
280
|
key: item,
|
|
272
|
-
label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion), labelWidth), labelWidth),
|
|
281
|
+
label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
|
|
273
282
|
detail: input.settingsPage === 'root'
|
|
274
283
|
? adaptiveSettingsRootValue(item)
|
|
275
284
|
: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
|