@ciphore/radiocli 0.1.2 → 0.1.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.
@@ -1,5 +1,5 @@
1
1
  import { mediaActionLabel } from './app-state.js';
2
- export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, screen }) {
2
+ export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen }) {
3
3
  if (capturingTransportAction) {
4
4
  return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
5
5
  }
@@ -33,10 +33,24 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
33
33
  return '↑/↓ or n/p move · Enter tune · f favorite · [/] page · b home';
34
34
  }
35
35
  if (screen === 'now-playing') {
36
+ if (playbackBackend === 'ffplay') {
37
+ return 'ffplay fallback: install mpv for pause/mute/media keys · f favorite · s sleep · d diagnostics · b home';
38
+ }
39
+ if (playbackBackend === 'airplay') {
40
+ return 'AirPlay: +/- volume · m mute · f favorite · s sleep · d diagnostics · b home';
41
+ }
36
42
  return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home';
37
43
  }
38
44
  if (screen === 'settings') {
39
- return 'Enter change selected · g Radio Garden · l location · x skip · o backend · r health · b home';
45
+ return 'Enter change selected · g Radio Garden · l location · x skip · o output · a AirPlay · r health · b home';
46
+ }
47
+ if (screen === 'airplay-settings') {
48
+ return canEnterAirPlayCode
49
+ ? '↑/↓ choose · Enter select receiver · c code · r refresh · b settings'
50
+ : '↑/↓ choose · Enter select receiver · r refresh · b settings';
51
+ }
52
+ if (screen === 'airplay-code') {
53
+ return 'Type receiver code · Backspace edit · Enter submit · Esc AirPlay';
40
54
  }
41
55
  if (screen === 'stats') {
42
56
  return 'b home';
@@ -28,6 +28,7 @@ export function playbackFooterText({ station, playback, metadata, sleepLabel, wi
28
28
  statePrefix(playback.state),
29
29
  nameLabel,
30
30
  loading ? 'buffering…' : trackLabel(metadata, stationName),
31
+ outputLabel(playback),
31
32
  playback.muted ? 'muted' : `vol ${playback.volume}`,
32
33
  sleepLabel !== 'Sleep off' ? sleepLabel : undefined
33
34
  ].filter(Boolean);
@@ -39,6 +40,12 @@ function statePrefix(state) {
39
40
  }
40
41
  return undefined;
41
42
  }
43
+ function outputLabel(playback) {
44
+ if (playback.backend !== 'airplay') {
45
+ return undefined;
46
+ }
47
+ return playback.airPlayDeviceName ? `AirPlay ${playback.airPlayDeviceName}` : 'AirPlay';
48
+ }
42
49
  function trackLabel(metadata, stationName) {
43
50
  const title = metadata?.title?.trim();
44
51
  if (!title || title.toLowerCase() === stationName.toLowerCase()) {
@@ -6,14 +6,15 @@ export const homeItems = [
6
6
  { screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
7
7
  { screen: 'nearby', label: 'Nearby', detail: 'Opt-in approximate location for local stations' },
8
8
  { screen: 'stats', label: 'Stats', detail: 'Listening graph, stations, streaks, hours' },
9
- { screen: 'settings', label: 'Settings', detail: 'Playback backend, colors, providers' }
9
+ { screen: 'settings', label: 'Settings', detail: 'Audio output, colors, providers' }
10
10
  ];
11
11
  export const settingsItems = [
12
12
  'Cycle display color',
13
13
  'Cycle receiver style',
14
14
  'Toggle Radio Garden experimental adapter',
15
15
  'Toggle nearby location lookup',
16
- 'Cycle playback backend',
16
+ 'Audio output',
17
+ 'AirPlay receiver',
17
18
  'Volume up',
18
19
  'Volume down',
19
20
  'Mute or unmute',
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { ScreenHeader } from '../components/ScreenHeader.js';
4
+ import { truncate } from '../format.js';
5
+ import { textDim, themeAccent } from '../theme.js';
6
+ export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
7
+ const accent = themeAccent(theme);
8
+ const lineWidth = Math.max(24, width - 4);
9
+ const promptActive = isAirPlayCodePromptActive(playback);
10
+ const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
11
+ 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: "gray", children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: "gray", children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
13
+ ? '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: "gray", bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: "Enter submits \u00B7 Backspace edits \u00B7 Esc returns to AirPlay" })] })] }));
15
+ }
16
+ export function isAirPlayCodePromptActive(playback) {
17
+ return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
18
+ }
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { airPlayAvailability, airPlayDeviceDetail, selectedAirPlayDevice } from '../airplay-settings.js';
4
+ import { audioOutputLabel } from '../audio-output.js';
5
+ import { Menu, Pointer } from '../components/Menu.js';
6
+ import { ScreenHeader } from '../components/ScreenHeader.js';
7
+ import { truncate } from '../format.js';
8
+ import { textDim, themeAccent } from '../theme.js';
9
+ export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width }) {
10
+ const accent = themeAccent(theme);
11
+ const availability = airPlayAvailability(backends, devices);
12
+ const selectedDevice = selectedAirPlayDevice(settings, devices);
13
+ const selectedMissing = Boolean(settings.preferredAirPlayDevice && !selectedDevice);
14
+ const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
15
+ const lineWidth = Math.max(24, width - 4);
16
+ const preferredOutput = audioOutputLabel(settings.preferredBackend);
17
+ 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: "gray", children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? 'gray' : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: "gray", children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: "gray", children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: devices, selected: selected, keyFor: device => device.id, render: (device, _index, active) => {
18
+ const isSelected = device.id === settings.preferredAirPlayDevice;
19
+ const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
20
+ const selectedLabel = isSelected ? ' selected' : '';
21
+ const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
22
+ 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, _jsxs(Text, { color: "gray", children: [" \u00B7 ", detail] })] }));
23
+ } })) : (_jsx(Text, { color: "gray", children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
24
+ }
@@ -4,6 +4,7 @@ import { Logo } from '../components/Logo.js';
4
4
  import { Menu, Pointer } from '../components/Menu.js';
5
5
  import { themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
+ import { playbackBackendLabel } from '../../player/backend-install.js';
7
8
  export function HomeScreen({ selected, theme, library, playback }) {
8
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playback.backend] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
9
10
  }
@@ -5,16 +5,19 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { truncate } from '../format.js';
6
6
  import { settingsItems } from '../screen-items.js';
7
7
  import { themeAccent } from '../theme.js';
8
- export function SettingsScreen({ selected, settings, storePath, playback, backends, providerHealth, theme, diagnostics, width }) {
8
+ import { playbackBackendCapabilities } from '../../player/backend-install.js';
9
+ import { airPlayReceiverSettingValue } from '../airplay-settings.js';
10
+ import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
11
+ export function SettingsScreen({ selected, settings, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width }) {
9
12
  const accent = themeAccent(theme);
10
13
  const lineWidth = Math.max(32, width - 4);
11
14
  const health = Object.entries(providerHealth);
12
15
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: settingsItems, selected: selected, keyFor: item => item, render: (item, _index, active) => {
13
- const value = settingValue(item, settings, diagnostics, backends);
16
+ const value = settingValue(item, settings, diagnostics, backends, airPlayDevices);
14
17
  return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: item }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
15
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Player: ", _jsx(Text, { color: accent, children: playback.backend || 'none' }), " / ", playback.state, " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: "gray", children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: "gray", children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: "gray", children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
18
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: "gray", children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: "gray", children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: "gray", children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
16
19
  }
17
- function settingValue(item, settings, diagnostics, backends) {
20
+ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
18
21
  switch (item) {
19
22
  case 'Cycle display color':
20
23
  return settings.theme;
@@ -24,9 +27,14 @@ function settingValue(item, settings, diagnostics, backends) {
24
27
  return settings.enableRadioGarden ? 'on' : 'off';
25
28
  case 'Toggle nearby location lookup':
26
29
  return settings.enableNearbyLocation ? 'on' : 'off';
27
- case 'Cycle playback backend':
28
- return `${settings.preferredBackend} · available ${backends.length ? backends.join(', ') : 'none'}`;
30
+ case 'Audio output':
31
+ return audioOutputSettingValue(settings, diagnostics, backends);
32
+ case 'AirPlay receiver':
33
+ return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
29
34
  case 'Mute or unmute':
35
+ if (diagnostics.backend === 'ffplay' && !playbackBackendCapabilities(diagnostics.backend).supportsMute) {
36
+ return 'requires mpv';
37
+ }
30
38
  return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
31
39
  case 'Toggle skip broken streams':
32
40
  return settings.skipBrokenStreams ? 'on' : 'off';
@@ -3,7 +3,7 @@ import { useInput } from 'ink';
3
3
  import { homeItems, settingsItems } from './screen-items.js';
4
4
  import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
5
5
  import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
6
- export function useAppInput({ adjustVolume, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, setCapturingTransportAction, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
6
+ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
7
7
  useEffect(() => {
8
8
  const onData = (data) => {
9
9
  const rawInput = String(data);
@@ -43,7 +43,7 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
43
43
  }
44
44
  else if (action === 'playPause') {
45
45
  lastRawTransportAtRef.current = Date.now();
46
- void player.togglePause();
46
+ togglePause();
47
47
  }
48
48
  };
49
49
  stdin.on('data', onData);
@@ -64,7 +64,8 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
64
64
  setCapturingTransportAction,
65
65
  setMessage,
66
66
  settingsRef,
67
- stdin
67
+ stdin,
68
+ togglePause
68
69
  ]);
69
70
  useInput((input, key) => {
70
71
  if (key.ctrl && input === 'c') {
@@ -148,6 +149,21 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
148
149
  }
149
150
  return;
150
151
  }
152
+ if (screen === 'airplay-code') {
153
+ if (key.return) {
154
+ submitAirPlayCode(airPlayCode);
155
+ return;
156
+ }
157
+ if (key.escape) {
158
+ setAirPlayCode('');
159
+ go('airplay-settings');
160
+ return;
161
+ }
162
+ if (isEditableInput(input, key)) {
163
+ setAirPlayCode(value => applyTextInput(value, input, key).slice(0, 64));
164
+ }
165
+ return;
166
+ }
151
167
  if (screen === 'explore') {
152
168
  const exploreMove = exploreMoveForInput(input);
153
169
  if (exploreMove) {
@@ -199,7 +215,15 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
199
215
  return;
200
216
  }
201
217
  if (input === 'o') {
202
- cyclePlaybackBackend();
218
+ cycleAudioOutput();
219
+ return;
220
+ }
221
+ if (input === 'a' && screen === 'settings') {
222
+ openAirPlaySettings();
223
+ return;
224
+ }
225
+ if (input === 'c' && screen === 'airplay-settings' && canEnterAirPlayCode) {
226
+ openAirPlayCode();
203
227
  return;
204
228
  }
205
229
  if (input === 'g') {
@@ -214,6 +238,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
214
238
  toggleSkipBrokenStreams();
215
239
  return;
216
240
  }
241
+ if (input === 'r' && screen === 'airplay-settings') {
242
+ refreshAirPlayTargets();
243
+ return;
244
+ }
217
245
  if (input === 'r') {
218
246
  refreshProviderHealth();
219
247
  setMessage('Provider health refreshed.');
@@ -244,6 +272,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
244
272
  }
245
273
  return;
246
274
  }
275
+ if ((input === 'b' || key.escape) && screen === 'airplay-settings') {
276
+ go('settings');
277
+ return;
278
+ }
247
279
  if (input === 'b' || key.escape) {
248
280
  go('home');
249
281
  return;
@@ -270,7 +302,7 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
270
302
  return;
271
303
  }
272
304
  if (input === ' ') {
273
- void player.togglePause();
305
+ togglePause();
274
306
  return;
275
307
  }
276
308
  if (input === 'n' && screen === 'now-playing') {
@@ -326,8 +358,11 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
326
358
  else if (item === 'Toggle nearby location lookup') {
327
359
  toggleNearbyLocation();
328
360
  }
329
- else if (item === 'Cycle playback backend') {
330
- cyclePlaybackBackend();
361
+ else if (item === 'Audio output') {
362
+ cycleAudioOutput();
363
+ }
364
+ else if (item === 'AirPlay receiver') {
365
+ openAirPlaySettings();
331
366
  }
332
367
  else if (item === 'Volume up') {
333
368
  adjustVolume(5);
@@ -359,6 +394,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
359
394
  }
360
395
  return;
361
396
  }
397
+ if (screen === 'airplay-settings') {
398
+ selectAirPlayDeviceAt(selected);
399
+ return;
400
+ }
362
401
  if (screen === 'map') {
363
402
  const country = filteredCountries[selected];
364
403
  if (country) {
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
3
- export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
3
+ export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
4
4
  return useCallback(async (rawCommand) => {
5
5
  const trimmed = rawCommand.trim();
6
6
  if (!trimmed) {
@@ -96,6 +96,21 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
96
96
  setMessage(`Learned keys: prev ${mediaKeys.previous.length}, play ${mediaKeys.playPause.length}, next ${mediaKeys.next.length}. Use :keys reset to clear.`);
97
97
  return;
98
98
  }
99
+ if (name === 'airplay-code' || name === 'airplay-passcode') {
100
+ if (!value.trim()) {
101
+ go('airplay-code');
102
+ return;
103
+ }
104
+ const result = player.submitAirPlayPasscode(value.trim());
105
+ if (result.message) {
106
+ setMessage(result.message);
107
+ }
108
+ return;
109
+ }
110
+ if (name === 'airplay' || name === 'airplay-settings') {
111
+ openAirPlaySettings();
112
+ return;
113
+ }
99
114
  if (name === 'sleep') {
100
115
  const minutes = Number(value);
101
116
  setSleepUntil(Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : null);
@@ -132,6 +147,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
132
147
  countries,
133
148
  go,
134
149
  loadCountry,
150
+ openAirPlaySettings,
135
151
  openLibrary,
136
152
  player,
137
153
  playingStation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -66,9 +66,13 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "ink": "^7.0.5",
69
+ "node-airtunes2": "2.5.0",
69
70
  "react": "^19.2.6",
70
71
  "zod": "^4.4.3"
71
72
  },
73
+ "overrides": {
74
+ "protobufjs": "^7.5.8"
75
+ },
72
76
  "devDependencies": {
73
77
  "@types/node": "^25.9.1",
74
78
  "@types/react": "^19.2.15",