@ciphore/radiocli 0.1.2 → 0.1.3

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/dist/ui/App.js CHANGED
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
4
4
  import { ProviderManager } from '../providers/provider-manager.js';
5
5
  import { PlayerController } from '../player/player-controller.js';
6
- import { playbackBackendInstallHint } from '../player/backend-install.js';
6
+ import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
7
7
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
8
8
  import { receiverStyleNames } from '../types.js';
9
9
  import { appBackground, nextReceiverStyle, nextTheme, panelBackground, textDim, themeAccent } from './theme.js';
@@ -17,7 +17,7 @@ import { pageFooterText } from './page-footer.js';
17
17
  import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell } from './terminal-mouse.js';
18
18
  import { useAppInput } from './use-app-input.js';
19
19
  import { useCommandExecutor } from './use-command-executor.js';
20
- import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
20
+ import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextAirPlayDeviceId, nextPlaybackBackend, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
21
21
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
22
22
  const LIVE_RECEIVER_PULSE_MS = 80;
23
23
  const AMBIENT_RECEIVER_PULSE_MS = 140;
@@ -35,6 +35,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
35
35
  const player = useMemo(() => new PlayerController(() => settingsRef.current), []);
36
36
  const [playback, setPlayback] = useState(() => player.getState());
37
37
  const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends());
38
+ const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
38
39
  const [screen, setScreen] = useState('home');
39
40
  const [selected, setSelected] = useState(0);
40
41
  const [message, setMessage] = useState(null);
@@ -214,6 +215,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
214
215
  useEffect(() => {
215
216
  const backends = player.refreshDetectedBackends();
216
217
  setAvailableBackends(backends);
218
+ void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
217
219
  if (backends.length === 0) {
218
220
  setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
219
221
  }
@@ -503,17 +505,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
503
505
  setLibrary(store.toggleFavorite(station));
504
506
  setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
505
507
  }, [store]);
508
+ const showControlResult = useCallback((result) => {
509
+ if (!result.ok && result.message) {
510
+ setMessage(result.message);
511
+ }
512
+ }, []);
506
513
  const setVolume = useCallback((volume) => {
507
514
  const clamped = clampVolume(volume);
508
- updateSettings({ volume: clamped });
509
- void player.setVolume(clamped);
510
- }, [player, updateSettings]);
515
+ void player.setVolume(clamped).then(result => {
516
+ if (result.ok) {
517
+ updateSettings({ volume: clamped });
518
+ }
519
+ showControlResult(result);
520
+ });
521
+ }, [player, showControlResult, updateSettings]);
511
522
  const adjustVolume = useCallback((delta) => {
512
523
  setVolume((player.getState().volume || library.settings.volume) + delta);
513
524
  }, [library.settings.volume, player, setVolume]);
514
525
  const toggleMute = useCallback(() => {
515
- void player.toggleMute();
516
- }, [player]);
526
+ void player.toggleMute().then(showControlResult);
527
+ }, [player, showControlResult]);
528
+ const togglePause = useCallback(() => {
529
+ void player.togglePause().then(showControlResult);
530
+ }, [player, showControlResult]);
517
531
  const cycleDisplayColor = useCallback(() => {
518
532
  const theme = nextTheme(settingsRef.current.theme);
519
533
  updateSettings({ theme });
@@ -536,11 +550,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
536
550
  setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
537
551
  }, [updateSettings]);
538
552
  const cyclePlaybackBackend = useCallback(() => {
539
- const current = settingsRef.current.preferredBackend;
540
- const preferredBackend = current === 'auto' ? 'mpv' : current === 'mpv' ? 'ffplay' : 'auto';
553
+ const preferredBackend = nextPlaybackBackend(settingsRef.current.preferredBackend);
541
554
  updateSettings({ preferredBackend });
542
555
  setMessage(`Playback backend: ${preferredBackend}`);
543
556
  }, [updateSettings]);
557
+ const cycleAirPlayTarget = useCallback(() => {
558
+ void player.refreshAirPlayDevices().then(devices => {
559
+ setAvailableAirPlayDevices(devices);
560
+ const preferredAirPlayDevice = nextAirPlayDeviceId(settingsRef.current.preferredAirPlayDevice, devices);
561
+ updateSettings({ preferredAirPlayDevice });
562
+ const selectedAirPlayDevice = devices.find(device => device.id === preferredAirPlayDevice);
563
+ setMessage(`AirPlay target: ${selectedAirPlayDevice?.name ?? 'auto'}`);
564
+ }).catch(() => {
565
+ setAvailableAirPlayDevices([]);
566
+ updateSettings({ preferredAirPlayDevice: undefined });
567
+ setMessage('No AirPlay receivers found.');
568
+ });
569
+ }, [player, updateSettings]);
544
570
  const toggleSkipBrokenStreams = useCallback(() => {
545
571
  const skipBrokenStreams = !settingsRef.current.skipBrokenStreams;
546
572
  updateSettings({ skipBrokenStreams });
@@ -648,6 +674,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
648
674
  commandMode,
649
675
  commandText,
650
676
  currentItemCount,
677
+ cycleAirPlayTarget,
651
678
  cycleDisplayColor,
652
679
  cyclePlaybackBackend,
653
680
  cycleReceiverStyle,
@@ -691,6 +718,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
691
718
  stdin,
692
719
  toggleFavorite,
693
720
  toggleMute,
721
+ togglePause,
694
722
  toggleNearbyLocation,
695
723
  toggleRadioGarden,
696
724
  toggleSkipBrokenStreams
@@ -699,7 +727,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
699
727
  return itemCountsRef.current[currentScreen] ?? 0;
700
728
  }
701
729
  const hasTopTabs = !layout.compact;
702
- const globalFooter = '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
730
+ const globalFooter = playback.backend === 'ffplay'
731
+ ? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · q quit'
732
+ : playback.backend === 'airplay'
733
+ ? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · q quit'
734
+ : '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
703
735
  const playbackFooter = playbackFooterText({
704
736
  station: playingStation,
705
737
  playback,
@@ -716,9 +748,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
716
748
  commandText,
717
749
  editingCountryFilter,
718
750
  editingSearch,
751
+ playbackBackend: playback.backend,
719
752
  screen
720
753
  });
721
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: appBackground, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: appBackground, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playback.backend || 'no backend'} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: appBackground, children: [_jsx(AppContent, { backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), message ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: themeAccent(theme), children: message }) })) : null] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: panelBackground, children: [playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : 'gray', children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }));
754
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: appBackground, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: appBackground, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: appBackground, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), message ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: themeAccent(theme), children: message }) })) : null] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: panelBackground, children: [playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : 'gray', children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }));
722
755
  }
723
756
  function buildLibraryStations(library) {
724
757
  const stations = [];
@@ -10,9 +10,10 @@ import { StationScreen } from './screens/StationScreen.js';
10
10
  import { NowPlayingScreen } from './screens/NowPlayingScreen.js';
11
11
  import { StatsScreen } from './screens/StatsScreen.js';
12
12
  import { SettingsScreen } from './screens/SettingsScreen.js';
13
- export function AppContent({ backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme }) {
13
+ import { playbackBackendLabel } from '../player/backend-install.js';
14
+ export function AppContent({ airPlayDevices, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme }) {
14
15
  if (layout.compact) {
15
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: "gray", children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: "gray", children: ["Playback: ", playback.state, " \u00B7 ", playback.backend] }), _jsx(Text, { color: "gray", children: "q quit \u00B7 Ctrl+C always exits" })] }));
16
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: "gray", children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: "gray", children: ["Playback: ", playback.state, " \u00B7 ", playbackBackendLabel(playback.backend)] }), _jsx(Text, { color: "gray", children: "q quit \u00B7 Ctrl+C always exits" })] }));
16
17
  }
17
18
  if (screen === 'home') {
18
19
  return _jsx(HomeScreen, { selected: selected, theme: theme, library: library, playback: playback });
@@ -39,7 +40,7 @@ export function AppContent({ backends, countryFilter, diagnostics, displayStatio
39
40
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
40
41
  }
41
42
  if (screen === 'settings') {
42
- return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
43
+ return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
43
44
  }
44
45
  return _jsx(Text, { children: "Unknown screen." });
45
46
  }
@@ -5,6 +5,7 @@ const emptyMediaKeyBindings = {
5
5
  };
6
6
  const mediaTransportActions = ['previous', 'playPause', 'next'];
7
7
  const sleepTimerOptions = [null, 15, 30, 60];
8
+ const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'airplay'];
8
9
  export const defaultExploreCursor = {
9
10
  latitude: 48.8566,
10
11
  longitude: 2.3522
@@ -87,6 +88,24 @@ export function nextSleepTimerMinutes(currentMinutes) {
87
88
  const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % sleepTimerOptions.length : 0;
88
89
  return sleepTimerOptions[nextIndex] ?? null;
89
90
  }
91
+ export function nextPlaybackBackend(current) {
92
+ const index = playbackBackendOptions.indexOf(current);
93
+ return playbackBackendOptions[(index + 1) % playbackBackendOptions.length] ?? 'auto';
94
+ }
95
+ export function nextAirPlayDeviceId(current, devices) {
96
+ if (devices.length === 0) {
97
+ return undefined;
98
+ }
99
+ const ids = devices.map(device => device.id);
100
+ if (!current) {
101
+ return ids[0];
102
+ }
103
+ const index = ids.indexOf(current);
104
+ if (index === -1 || index === ids.length - 1) {
105
+ return undefined;
106
+ }
107
+ return ids[index + 1];
108
+ }
90
109
  export function moveExploreCursor(cursor, direction, fast = false) {
91
110
  const latitudeStep = fast ? 6 : 1;
92
111
  const longitudeStep = fast ? 12 : 2;
@@ -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, playbackBackend, screen }) {
3
3
  if (capturingTransportAction) {
4
4
  return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
5
5
  }
@@ -33,10 +33,16 @@ 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 backend · a AirPlay · r health · b home';
40
46
  }
41
47
  if (screen === 'stats') {
42
48
  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()) {
@@ -14,6 +14,7 @@ export const settingsItems = [
14
14
  'Toggle Radio Garden experimental adapter',
15
15
  'Toggle nearby location lookup',
16
16
  'Cycle playback backend',
17
+ 'Cycle AirPlay target',
17
18
  'Volume up',
18
19
  'Volume down',
19
20
  'Mute or unmute',
@@ -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,17 @@ 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, playbackBackendLabel } from '../../player/backend-install.js';
9
+ export function SettingsScreen({ selected, settings, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width }) {
9
10
  const accent = themeAccent(theme);
10
11
  const lineWidth = Math.max(32, width - 4);
11
12
  const health = Object.entries(providerHealth);
12
13
  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);
14
+ const value = settingValue(item, settings, diagnostics, backends, airPlayDevices);
14
15
  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)] })] })] }));
16
+ } }) }), _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: playbackBackendLabel(playback.backend) }), " / ", 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)] })] })] }));
16
17
  }
17
- function settingValue(item, settings, diagnostics, backends) {
18
+ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
18
19
  switch (item) {
19
20
  case 'Cycle display color':
20
21
  return settings.theme;
@@ -25,8 +26,15 @@ function settingValue(item, settings, diagnostics, backends) {
25
26
  case 'Toggle nearby location lookup':
26
27
  return settings.enableNearbyLocation ? 'on' : 'off';
27
28
  case 'Cycle playback backend':
28
- return `${settings.preferredBackend} · available ${backends.length ? backends.join(', ') : 'none'}`;
29
+ return `${settings.preferredBackend} · available ${backends.length ? backends.map(playbackBackendLabel).join(', ') : 'none'}`;
30
+ case 'Cycle AirPlay target': {
31
+ const device = airPlayDevices.find(candidate => candidate.id === settings.preferredAirPlayDevice);
32
+ return `${device?.name ?? settings.preferredAirPlayDevice ?? 'auto'} · ${airPlayDevices.length || 'no'} found`;
33
+ }
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, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleAirPlayTarget, 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, 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') {
@@ -202,6 +203,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
202
203
  cyclePlaybackBackend();
203
204
  return;
204
205
  }
206
+ if (input === 'a' && screen === 'settings') {
207
+ cycleAirPlayTarget();
208
+ return;
209
+ }
205
210
  if (input === 'g') {
206
211
  toggleRadioGarden();
207
212
  return;
@@ -270,7 +275,7 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
270
275
  return;
271
276
  }
272
277
  if (input === ' ') {
273
- void player.togglePause();
278
+ togglePause();
274
279
  return;
275
280
  }
276
281
  if (input === 'n' && screen === 'now-playing') {
@@ -329,6 +334,9 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
329
334
  else if (item === 'Cycle playback backend') {
330
335
  cyclePlaybackBackend();
331
336
  }
337
+ else if (item === 'Cycle AirPlay target') {
338
+ cycleAirPlayTarget();
339
+ }
332
340
  else if (item === 'Volume up') {
333
341
  adjustVolume(5);
334
342
  }
@@ -96,6 +96,14 @@ 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
+ setMessage('Usage: :airplay-code <code>');
102
+ return;
103
+ }
104
+ player.submitAirPlayPasscode(value.trim());
105
+ return;
106
+ }
99
107
  if (name === 'sleep') {
100
108
  const minutes = Number(value);
101
109
  setSleepUntil(Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",