@ciphore/radiocli 0.2.3 → 0.2.5
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 +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -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 +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- 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/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- 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 +28 -21
- 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/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -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
|
+
}
|
|
@@ -5,9 +5,9 @@ import { computeListeningStats } from '../activity/stats.js';
|
|
|
5
5
|
import { playbackBackendLabel } from '../player/backend-install.js';
|
|
6
6
|
import { visibleWindow } from './list-window.js';
|
|
7
7
|
import { displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate } from './format.js';
|
|
8
|
-
import { homeItems, settingsGroup,
|
|
8
|
+
import { homeItems, settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage } from './screen-items.js';
|
|
9
9
|
import { screenTitle } from './screen-meta.js';
|
|
10
|
-
import { keyHelpSections,
|
|
10
|
+
import { keyHelpSections, commandHelpForAirPlay } from './help-content.js';
|
|
11
11
|
import { settingLabel, settingValue } from './screens/SettingsScreen.js';
|
|
12
12
|
import { exploreMapLand, mapMarker, panelBorder, textDim, textMuted, themeAccent } from './theme.js';
|
|
13
13
|
import { panelBorderStyle, useDisplay } from './display-context.js';
|
|
@@ -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,9 +54,12 @@ function AdaptiveContentBody(props) {
|
|
|
54
54
|
selected,
|
|
55
55
|
width,
|
|
56
56
|
mode,
|
|
57
|
-
ascii
|
|
57
|
+
ascii,
|
|
58
|
+
airPlaySupported,
|
|
59
|
+
platform: props.platform
|
|
58
60
|
});
|
|
59
61
|
const empty = adaptiveEmptyState({
|
|
62
|
+
airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
|
|
60
63
|
screen,
|
|
61
64
|
searchQuery,
|
|
62
65
|
editingSearch,
|
|
@@ -67,7 +70,7 @@ function AdaptiveContentBody(props) {
|
|
|
67
70
|
stationTitle,
|
|
68
71
|
stationError: props.stationError
|
|
69
72
|
});
|
|
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:
|
|
73
|
+
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
74
|
}
|
|
72
75
|
if (screen === 'stats') {
|
|
73
76
|
const stats = computeListeningStats(library.activity.sessions);
|
|
@@ -86,7 +89,7 @@ function AdaptiveContentBody(props) {
|
|
|
86
89
|
}
|
|
87
90
|
if (screen === 'airplay-code') {
|
|
88
91
|
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 }));
|
|
92
|
+
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
93
|
}
|
|
91
94
|
const rows = adaptiveRows({
|
|
92
95
|
screen,
|
|
@@ -103,9 +106,12 @@ function AdaptiveContentBody(props) {
|
|
|
103
106
|
selected,
|
|
104
107
|
width,
|
|
105
108
|
mode,
|
|
106
|
-
ascii
|
|
109
|
+
ascii,
|
|
110
|
+
airPlaySupported,
|
|
111
|
+
platform: props.platform
|
|
107
112
|
});
|
|
108
113
|
const empty = adaptiveEmptyState({
|
|
114
|
+
airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
|
|
109
115
|
screen,
|
|
110
116
|
searchQuery,
|
|
111
117
|
editingSearch,
|
|
@@ -116,11 +122,12 @@ function AdaptiveContentBody(props) {
|
|
|
116
122
|
stationTitle,
|
|
117
123
|
stationError: props.stationError
|
|
118
124
|
});
|
|
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:
|
|
125
|
+
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
126
|
}
|
|
121
127
|
function AdaptiveExplore({ mode, width, height, theme, stations, selected, favorites, cursor, loading, error, ascii, reduceMotion }) {
|
|
122
128
|
const accent = themeAccent(theme);
|
|
123
129
|
const { headerRows, headerGap, bodyRows } = adaptiveExploreFrameMetrics(mode, height);
|
|
130
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
124
131
|
const layout = computeAdaptiveExploreLayout(mode, width, Math.max(1, bodyRows));
|
|
125
132
|
const marker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
|
|
126
133
|
const map = React.useMemo(() => buildCosmoWorldMap(layout.mapColumns, layout.mapRows, marker), [layout.mapColumns, layout.mapRows, marker]);
|
|
@@ -134,10 +141,10 @@ function AdaptiveExplore({ mode, width, height, theme, stations, selected, favor
|
|
|
134
141
|
? [{ key: 'error', label: error }]
|
|
135
142
|
: [{ key: 'empty', label: 'No stations near this point.' }];
|
|
136
143
|
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] }));
|
|
144
|
+
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
145
|
}
|
|
139
146
|
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) => {
|
|
147
|
+
return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", flexShrink: 0, "aria-hidden": true, children: rows.map((row, rowIndex) => {
|
|
141
148
|
const chunks = [];
|
|
142
149
|
for (const cell of row.cells) {
|
|
143
150
|
const previous = chunks.at(-1);
|
|
@@ -166,11 +173,12 @@ function adaptiveMapColor(kind, theme) {
|
|
|
166
173
|
function AdaptiveFrame({ title, status, mode, height, width, theme, children }) {
|
|
167
174
|
const accent = themeAccent(theme);
|
|
168
175
|
const { ascii } = useDisplay();
|
|
176
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
169
177
|
const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI ';
|
|
170
178
|
const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width);
|
|
171
179
|
const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1);
|
|
172
180
|
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] }));
|
|
181
|
+
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
182
|
}
|
|
175
183
|
function adaptiveFrameMetrics(mode, height) {
|
|
176
184
|
const headerRows = mode === 'compact' && height >= 4 ? 2 : 1;
|
|
@@ -182,6 +190,8 @@ function adaptiveFrameMetrics(mode, height) {
|
|
|
182
190
|
}
|
|
183
191
|
function AdaptiveHome({ mode, selected, height, width, theme, library }) {
|
|
184
192
|
const gapRows = height >= 5 ? 1 : 0;
|
|
193
|
+
const { ascii } = useDisplay();
|
|
194
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
185
195
|
const showSummary = mode === 'compact' && height >= 10;
|
|
186
196
|
const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0));
|
|
187
197
|
const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1);
|
|
@@ -191,8 +201,8 @@ function AdaptiveHome({ mode, selected, height, width, theme, library }) {
|
|
|
191
201
|
const absoluteIndex = window.start + offset;
|
|
192
202
|
const active = absoluteIndex === selectedIndex;
|
|
193
203
|
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] }));
|
|
204
|
+
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));
|
|
205
|
+
}) }), 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
206
|
}
|
|
197
207
|
function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion }) {
|
|
198
208
|
const selectedIndex = Math.min(Math.max(selected, 0), Math.max(0, rows.length - 1));
|
|
@@ -205,35 +215,37 @@ function AdaptiveList({ rows, selected, pageSize, width, theme, reduceMotion })
|
|
|
205
215
|
const detail = row.detail ? `${row.separator ?? ' · '}${row.detail}` : '';
|
|
206
216
|
const text = `${row.label}${detail}`;
|
|
207
217
|
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));
|
|
218
|
+
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
219
|
}) }));
|
|
210
220
|
}
|
|
211
221
|
function StaticRows({ rows, width, theme }) {
|
|
212
222
|
const accent = themeAccent(theme);
|
|
213
|
-
|
|
223
|
+
const { ascii } = useDisplay();
|
|
224
|
+
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
225
|
}
|
|
215
226
|
function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height, ascii, theme, receiverStyle, reduceMotion }) {
|
|
216
227
|
const pulse = useReceiverPulse();
|
|
228
|
+
const { screenReader } = useDisplay();
|
|
217
229
|
const accent = themeAccent(theme);
|
|
218
230
|
const stationName = station?.name ?? 'No station tuned';
|
|
219
|
-
const showMetadata = mode === 'compact' && height >= 9
|
|
231
|
+
const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9);
|
|
220
232
|
const headerRows = mode === 'compact' ? 2 : 1;
|
|
221
233
|
const metadataRows = showMetadata ? 1 : 0;
|
|
222
234
|
const gapRows = height >= 5 ? 1 : 0;
|
|
223
235
|
const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2);
|
|
224
236
|
const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width);
|
|
225
|
-
const visualRows = buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
|
|
237
|
+
const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
|
|
226
238
|
const header = mode === 'micro'
|
|
227
239
|
? `${stationName} · ${playback.state}`
|
|
228
240
|
: 'RADIOCLI Now playing';
|
|
229
241
|
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'
|
|
242
|
+
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
243
|
? _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
|
|
244
|
+
: 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
245
|
? renderAdaptiveSegments(row.segments, ascii)
|
|
234
246
|
: ascii
|
|
235
247
|
? 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] }));
|
|
248
|
+
: 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
249
|
}
|
|
238
250
|
function renderAdaptiveSegments(segments, ascii) {
|
|
239
251
|
let offset = 0;
|
|
@@ -246,12 +258,13 @@ function renderAdaptiveSegments(segments, ascii) {
|
|
|
246
258
|
function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii, reduceMotion }) {
|
|
247
259
|
const accent = themeAccent(theme);
|
|
248
260
|
const { panel: panelBackground } = useDisplay();
|
|
261
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
249
262
|
const fieldText = query || 'station, genre, or place';
|
|
250
263
|
const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ ';
|
|
251
264
|
const fieldRows = height >= 4 ? 3 : 1;
|
|
252
265
|
const gapRows = height >= 6 ? 1 : 0;
|
|
253
266
|
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] }));
|
|
267
|
+
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
268
|
}
|
|
256
269
|
function adaptiveRows(input) {
|
|
257
270
|
const { screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii } = input;
|
|
@@ -264,15 +277,15 @@ function adaptiveRows(input) {
|
|
|
264
277
|
}));
|
|
265
278
|
}
|
|
266
279
|
if (screen === 'settings') {
|
|
267
|
-
const pageItems = settingsItemsForPage(input.settingsPage);
|
|
268
|
-
const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion));
|
|
280
|
+
const pageItems = settingsItemsForPage(input.settingsPage, input.airPlaySupported);
|
|
281
|
+
const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
|
|
269
282
|
const labelWidth = pairedColumnWidth(labels, width, mode);
|
|
270
283
|
return pageItems.map((item, index) => ({
|
|
271
284
|
key: item,
|
|
272
|
-
label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion), labelWidth), labelWidth),
|
|
285
|
+
label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
|
|
273
286
|
detail: input.settingsPage === 'root'
|
|
274
|
-
? adaptiveSettingsRootValue(item)
|
|
275
|
-
: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
|
|
287
|
+
? adaptiveSettingsRootValue(item, input.airPlaySupported)
|
|
288
|
+
: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion, input.platform),
|
|
276
289
|
separator: ' ',
|
|
277
290
|
index
|
|
278
291
|
}));
|
|
@@ -303,7 +316,7 @@ function adaptiveRows(input) {
|
|
|
303
316
|
}
|
|
304
317
|
}
|
|
305
318
|
rows.push({ key: 'section-commands', label: 'Commands', heading: true });
|
|
306
|
-
for (const command of
|
|
319
|
+
for (const command of commandHelpForAirPlay(input.airPlaySupported)) {
|
|
307
320
|
rows.push({ key: `command-${command.name}`, label: `:${command.name}${command.args ? ` ${command.args}` : ''}`, detail: command.description, index });
|
|
308
321
|
index += 1;
|
|
309
322
|
}
|
|
@@ -311,8 +324,8 @@ function adaptiveRows(input) {
|
|
|
311
324
|
}
|
|
312
325
|
return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii);
|
|
313
326
|
}
|
|
314
|
-
function adaptiveSettingsRootValue(item) {
|
|
315
|
-
const group =
|
|
327
|
+
function adaptiveSettingsRootValue(item, airPlayVisible) {
|
|
328
|
+
const group = settingsGroupsForAirPlay(airPlayVisible).find(candidate => candidate.label === item);
|
|
316
329
|
return group ? `${group.items.length} settings ›` : undefined;
|
|
317
330
|
}
|
|
318
331
|
function stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii) {
|
|
@@ -369,6 +382,8 @@ function adaptiveEmptyState(input) {
|
|
|
369
382
|
return [{ key: 'error', label: stationError }, { key: 'hint', label: 'Try again later or reopen this view.' }];
|
|
370
383
|
}
|
|
371
384
|
if (screen === 'airplay-settings') {
|
|
385
|
+
if (input.airPlaySetupRequired)
|
|
386
|
+
return [{ key: 'empty', label: 'Setup required' }, { key: 'hint', label: 'radiocli setup' }, { key: 'option', label: '--airplay' }];
|
|
372
387
|
return [{ key: 'empty', label: 'No AirPlay receivers found.' }, { key: 'hint', label: 'Press r to scan again.' }];
|
|
373
388
|
}
|
|
374
389
|
return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
|
|
@@ -406,6 +421,8 @@ function adaptiveStatus(props) {
|
|
|
406
421
|
return playingStation ? playingStation.name : 'Receiver authentication';
|
|
407
422
|
}
|
|
408
423
|
if (screen === 'airplay-settings') {
|
|
424
|
+
if (props.platform === 'linux' && !props.backends.includes('airplay'))
|
|
425
|
+
return 'Setup required';
|
|
409
426
|
return `${props.airPlayDevices.length} receivers · ${playbackBackendLabel(playback.backend)}`;
|
|
410
427
|
}
|
|
411
428
|
return `${stations.length.toLocaleString()} stations${playingStation ? ` · playing ${playingStation.name}` : ''}`;
|