@ciphore/radiocli 0.1.3 → 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.
package/dist/ui/App.js CHANGED
@@ -17,7 +17,10 @@ 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, nextAirPlayDeviceId, nextPlaybackBackend, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
20
+ import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
21
+ import { isAirPlayBackendAvailable } from './airplay-settings.js';
22
+ import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
23
+ import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
21
24
  const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
22
25
  const LIVE_RECEIVER_PULSE_MS = 80;
23
26
  const AMBIENT_RECEIVER_PULSE_MS = 140;
@@ -56,6 +59,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
56
59
  const [spinnerFrame, setSpinnerFrame] = useState(0);
57
60
  const [commandMode, setCommandMode] = useState(false);
58
61
  const [commandText, setCommandText] = useState('');
62
+ const [airPlayCode, setAirPlayCode] = useState('');
59
63
  const [filters, setFilters] = useState({ codec: null, language: null, minBitrate: null });
60
64
  const [sleepUntil, setSleepUntil] = useState(null);
61
65
  const [showDiagnostics, setShowDiagnostics] = useState(false);
@@ -118,6 +122,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
118
122
  'now-playing': 1,
119
123
  library: 0,
120
124
  stats: 1,
125
+ 'airplay-settings': 0,
126
+ 'airplay-code': 1,
121
127
  settings: settingsItems.length
122
128
  });
123
129
  itemCountsRef.current = {
@@ -131,12 +137,17 @@ export function App({ store: providedStore, providers: providedProviders }) {
131
137
  'now-playing': 1,
132
138
  library: stationCounts.library,
133
139
  stats: 1,
140
+ 'airplay-settings': availableAirPlayDevices.length,
141
+ 'airplay-code': 1,
134
142
  settings: settingsItems.length
135
143
  };
136
144
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
137
145
  displayStationsRef.current = displayStations;
138
146
  const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
139
147
  const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
148
+ const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
149
+ const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
150
+ Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
140
151
  const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
141
152
  const frameWidth = Math.max(40, layout.columns - 2);
142
153
  useEffect(() => player.onChange(setPlayback), [player]);
@@ -193,7 +204,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
193
204
  }, []);
194
205
  useEffect(() => {
195
206
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
196
- }, [displayStations.length, filteredCountries.length, screen]);
207
+ }, [availableAirPlayDevices.length, displayStations.length, filteredCountries.length, screen]);
197
208
  useEffect(() => {
198
209
  if (!sleepUntil) {
199
210
  return;
@@ -237,6 +248,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
237
248
  setLibrary(nextLibrary);
238
249
  return nextLibrary;
239
250
  }, [store]);
251
+ useEffect(() => {
252
+ if (!selectedAirPlayDevice?.local || settingsRef.current.preferredBackend !== 'airplay') {
253
+ return;
254
+ }
255
+ const preferredBackend = preferredLocalPlaybackBackend(availableBackends);
256
+ if (!preferredBackend) {
257
+ setMessage(`${selectedAirPlayDevice.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
258
+ return;
259
+ }
260
+ updateSettings({ preferredBackend });
261
+ setMessage(`${selectedAirPlayDevice.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
262
+ }, [availableBackends, selectedAirPlayDevice, updateSettings]);
240
263
  const updateMediaKeys = useCallback((mediaKeys) => {
241
264
  updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
242
265
  }, [updateSettings]);
@@ -264,6 +287,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
264
287
  setMessage(null);
265
288
  }
266
289
  }, []);
290
+ const openAirPlayCode = useCallback(() => {
291
+ setAirPlayCode('');
292
+ go('airplay-code', { resetSelection: true, clearMessage: false });
293
+ }, [go]);
294
+ useEffect(() => {
295
+ if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
296
+ openAirPlayCode();
297
+ }
298
+ }, [openAirPlayCode, playback.backend, playback.message]);
267
299
  const shutdown = useCallback(() => {
268
300
  store.finishActiveListeningSession();
269
301
  player.stop().finally(exit);
@@ -475,7 +507,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
475
507
  setPlayingStation(station);
476
508
  playbackQueueRef.current = queue;
477
509
  store.startListeningSession(station);
478
- setLibrary(store.addRecent(station));
510
+ const nextLibrary = store.addRecent(station);
511
+ if (screenRef.current === 'library') {
512
+ const nextLibraryStations = applyStationFilters(buildLibraryStations(nextLibrary), filters);
513
+ const nextLibraryIndex = nextLibraryStations.findIndex(item => stationMatches(item, station));
514
+ if (nextLibraryIndex >= 0) {
515
+ selectedByScreenRef.current.library = nextLibraryIndex;
516
+ setSelected(nextLibraryIndex);
517
+ }
518
+ }
519
+ setLibrary(nextLibrary);
479
520
  if (options.openNowPlaying) {
480
521
  go('now-playing');
481
522
  }
@@ -486,7 +527,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
486
527
  const currentList = queue.stations;
487
528
  const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
488
529
  const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
489
- if (settingsRef.current.skipBrokenStreams && nextStation) {
530
+ if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
490
531
  setMessage(`${message} Skipping to ${nextStation.name}.`);
491
532
  rememberQueueSelection(queue, currentIndex + 1);
492
533
  setTimeout(() => playStationRef.current(nextStation, { ...options, queue }), 250);
@@ -494,7 +535,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
494
535
  }
495
536
  setMessage(message);
496
537
  }
497
- }, [go, player, providers, queueFromCurrentList, rememberQueueSelection, store]);
538
+ }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
498
539
  playStationRef.current = playStation;
499
540
  const toggleFavorite = useCallback((station) => {
500
541
  if (!station) {
@@ -510,6 +551,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
510
551
  setMessage(result.message);
511
552
  }
512
553
  }, []);
554
+ const submitAirPlayCode = useCallback((code) => {
555
+ const result = player.submitAirPlayPasscode(code);
556
+ if (result.ok) {
557
+ setAirPlayCode('');
558
+ setMessage(result.message ?? 'AirPlay code sent.');
559
+ go('now-playing', { clearMessage: false });
560
+ return;
561
+ }
562
+ showControlResult(result);
563
+ }, [go, player, showControlResult]);
513
564
  const setVolume = useCallback((volume) => {
514
565
  const clamped = clampVolume(volume);
515
566
  void player.setVolume(clamped).then(result => {
@@ -549,24 +600,96 @@ export function App({ store: providedStore, providers: providedProviders }) {
549
600
  updateSettings({ enableNearbyLocation });
550
601
  setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
551
602
  }, [updateSettings]);
552
- const cyclePlaybackBackend = useCallback(() => {
553
- const preferredBackend = nextPlaybackBackend(settingsRef.current.preferredBackend);
554
- updateSettings({ preferredBackend });
555
- setMessage(`Playback backend: ${preferredBackend}`);
556
- }, [updateSettings]);
557
- const cycleAirPlayTarget = useCallback(() => {
558
- void player.refreshAirPlayDevices().then(devices => {
603
+ const refreshAirPlayTargets = useCallback(async (announce = true) => {
604
+ try {
605
+ const devices = await player.refreshAirPlayDevices();
559
606
  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(() => {
607
+ const preferredIndex = devices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
608
+ if (preferredIndex >= 0) {
609
+ selectedByScreenRef.current['airplay-settings'] = preferredIndex;
610
+ if (screenRef.current === 'airplay-settings') {
611
+ setSelected(clamp(preferredIndex, devices.length - 1));
612
+ }
613
+ }
614
+ if (announce) {
615
+ setMessage(devices.length > 0 ? `AirPlay receivers refreshed: ${devices.length} found.` : 'No AirPlay receivers found.');
616
+ }
617
+ return devices;
618
+ }
619
+ catch {
565
620
  setAvailableAirPlayDevices([]);
566
- updateSettings({ preferredAirPlayDevice: undefined });
567
- setMessage('No AirPlay receivers found.');
568
- });
569
- }, [player, updateSettings]);
621
+ if (announce) {
622
+ setMessage('AirPlay receiver refresh failed.');
623
+ }
624
+ return [];
625
+ }
626
+ }, [player]);
627
+ const openAirPlaySettings = useCallback(() => {
628
+ const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
629
+ selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
630
+ go('airplay-settings', { resetSelection: false });
631
+ void refreshAirPlayTargets(false);
632
+ }, [availableAirPlayDevices, go, refreshAirPlayTargets]);
633
+ const selectAirPlayDeviceAt = useCallback((index) => {
634
+ const device = availableAirPlayDevices[index];
635
+ if (!device) {
636
+ setMessage('No AirPlay receiver selected.');
637
+ return;
638
+ }
639
+ updateSettings({ preferredAirPlayDevice: device.id });
640
+ selectedByScreenRef.current['airplay-settings'] = index;
641
+ if (device.local) {
642
+ const preferredBackend = preferredLocalPlaybackBackend(availableBackends);
643
+ if (!preferredBackend) {
644
+ setMessage(`${device.name} is this Mac. Install mpv to use local playback instead of AirPlay.`);
645
+ return;
646
+ }
647
+ updateSettings({ preferredBackend });
648
+ if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
649
+ const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
650
+ setMessage(`${device.name} is this Mac. Switching audio to ${audioOutputLabel(preferredBackend)}...`);
651
+ void playStation(playingStation, { queue });
652
+ return;
653
+ }
654
+ setMessage(`${device.name} is this Mac. Audio output: ${audioOutputLabel(preferredBackend)}.`);
655
+ return;
656
+ }
657
+ if (!isAirPlayBackendAvailable(availableBackends)) {
658
+ setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
659
+ return;
660
+ }
661
+ updateSettings({ preferredBackend: 'airplay' });
662
+ if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
663
+ const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
664
+ setMessage(`Switching audio to AirPlay: ${device.name}...`);
665
+ void playStation(playingStation, { queue });
666
+ return;
667
+ }
668
+ setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`);
669
+ }, [availableAirPlayDevices, availableBackends, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings]);
670
+ const cycleAudioOutput = useCallback(() => {
671
+ const currentOutput = settingsRef.current.preferredBackend;
672
+ const activeNeedsSelectedOutput = Boolean(playingStation &&
673
+ shouldRetuneForAudioOutput(playback.state) &&
674
+ audioOutputCanApply(currentOutput, settingsRef.current) &&
675
+ audioOutputNeedsActiveSwitch(currentOutput, playback.backend, availableBackends));
676
+ const preferredBackend = activeNeedsSelectedOutput
677
+ ? currentOutput
678
+ : nextAvailablePlaybackBackend(currentOutput, availableBackends);
679
+ updateSettings({ preferredBackend });
680
+ if (preferredBackend === 'airplay' && !settingsRef.current.preferredAirPlayDevice) {
681
+ setMessage('Choose an AirPlay receiver first. Your current station will keep playing until you pick one.');
682
+ openAirPlaySettings();
683
+ return;
684
+ }
685
+ if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
686
+ const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
687
+ setMessage(`Switching audio to ${audioOutputSwitchLabel(preferredBackend, availableBackends)}...`);
688
+ void playStation(playingStation, { queue });
689
+ return;
690
+ }
691
+ setMessage(`Audio output: ${audioOutputSwitchLabel(preferredBackend, availableBackends)}.`);
692
+ }, [availableBackends, openAirPlaySettings, playback.backend, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings]);
570
693
  const toggleSkipBrokenStreams = useCallback(() => {
571
694
  const skipBrokenStreams = !settingsRef.current.skipBrokenStreams;
572
695
  updateSettings({ skipBrokenStreams });
@@ -605,10 +728,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
605
728
  else if (target === 'library') {
606
729
  openLibrary();
607
730
  }
731
+ else if (target === 'airplay-settings') {
732
+ openAirPlaySettings();
733
+ }
608
734
  else {
609
735
  go(target);
610
736
  }
611
- }, [go, loadExplore, loadNearby, openLibrary]);
737
+ }, [go, loadExplore, loadNearby, openAirPlaySettings, openLibrary]);
612
738
  const openAdjacentTab = useCallback((direction) => {
613
739
  const active = activeTabForScreen(screen);
614
740
  const currentIndex = topTabs.findIndex(tab => tab.screen === active);
@@ -623,6 +749,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
623
749
  countries,
624
750
  go,
625
751
  loadCountry,
752
+ openAirPlaySettings,
626
753
  openLibrary,
627
754
  player,
628
755
  playingStation,
@@ -669,14 +796,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
669
796
  }, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
670
797
  useAppInput({
671
798
  adjustVolume,
799
+ airPlayCode,
800
+ canEnterAirPlayCode,
672
801
  beginLearningTransportKey,
673
802
  capturingTransportAction,
674
803
  commandMode,
675
804
  commandText,
676
805
  currentItemCount,
677
- cycleAirPlayTarget,
678
806
  cycleDisplayColor,
679
- cyclePlaybackBackend,
807
+ cycleAudioOutput,
680
808
  cycleReceiverStyle,
681
809
  cycleSleepTimer,
682
810
  editingCountryFilter,
@@ -688,6 +816,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
688
816
  lastSubmittedSearchRef,
689
817
  loadCountry,
690
818
  openAdjacentTab,
819
+ openAirPlayCode,
820
+ openAirPlaySettings,
691
821
  openScreen,
692
822
  playAdjacent,
693
823
  playStation,
@@ -696,6 +826,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
696
826
  moveExploreCursor: moveExploreMapCursor,
697
827
  moveExploreCursorToCell: moveExploreMapCursorToCell,
698
828
  refreshProviderHealth,
829
+ refreshAirPlayTargets: () => {
830
+ void refreshAirPlayTargets();
831
+ },
699
832
  resetLearnedTransportKeys,
700
833
  runSearch,
701
834
  saveLearnedTransportKey,
@@ -703,16 +836,19 @@ export function App({ store: providedStore, providers: providedProviders }) {
703
836
  searchQuery,
704
837
  selected,
705
838
  selectedStation,
839
+ selectAirPlayDeviceAt,
706
840
  setCapturingTransportAction,
707
841
  setCommandMode,
708
842
  setCommandText,
709
843
  setCountryFilter,
844
+ setAirPlayCode,
710
845
  setEditingCountryFilter,
711
846
  setEditingSearch,
712
847
  setMessage,
713
848
  setSearchQuery,
714
849
  setSelected,
715
850
  setShowDiagnostics,
851
+ submitAirPlayCode,
716
852
  settingsRef,
717
853
  shutdown,
718
854
  stdin,
@@ -743,6 +879,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
743
879
  spinnerFrame
744
880
  });
745
881
  const pageFooter = pageFooterText({
882
+ canEnterAirPlayCode,
746
883
  capturingTransportAction,
747
884
  commandMode,
748
885
  commandText,
@@ -751,7 +888,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
751
888
  playbackBackend: playback.backend,
752
889
  screen
753
890
  });
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) })] })] }));
891
+ 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, airPlayCode: airPlayCode, 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) })] })] }));
755
892
  }
756
893
  function buildLibraryStations(library) {
757
894
  const stations = [];
@@ -798,6 +935,42 @@ function formatDistanceKm(distanceKm) {
798
935
  }
799
936
  return `${Math.round(distanceKm).toLocaleString()} km`;
800
937
  }
938
+ function nextAvailablePlaybackBackend(current, backends) {
939
+ const options = ['auto'];
940
+ for (const backend of ['mpv', 'ffplay', 'airplay']) {
941
+ if (backends.includes(backend)) {
942
+ options.push(backend);
943
+ }
944
+ }
945
+ const index = options.indexOf(current);
946
+ return options[(index + 1) % options.length] ?? 'auto';
947
+ }
948
+ function shouldRetuneForAudioOutput(state) {
949
+ return state === 'loading' || state === 'playing' || state === 'paused';
950
+ }
951
+ function audioOutputNeedsActiveSwitch(selectedOutput, activeBackend, backends) {
952
+ const resolved = resolvedAudioOutput(selectedOutput, backends);
953
+ return Boolean(resolved && activeBackend !== 'none' && activeBackend !== resolved);
954
+ }
955
+ function audioOutputCanApply(output, settings) {
956
+ return output !== 'airplay' || Boolean(settings.preferredAirPlayDevice);
957
+ }
958
+ function preferredLocalPlaybackBackend(backends) {
959
+ if (backends.includes('mpv')) {
960
+ return 'mpv';
961
+ }
962
+ if (backends.includes('ffplay')) {
963
+ return 'ffplay';
964
+ }
965
+ return null;
966
+ }
967
+ function audioOutputSwitchLabel(output, backends) {
968
+ const resolved = resolvedAudioOutput(output, backends);
969
+ if (output === 'auto' && resolved) {
970
+ return `${audioOutputLabel(resolved)} (automatic)`;
971
+ }
972
+ return audioOutputLabel(output);
973
+ }
801
974
  function librarySubtitle(library) {
802
975
  return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
803
976
  }
@@ -10,8 +10,11 @@ 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
+ import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
14
+ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
+ import { selectedAirPlayDevice } from './airplay-settings.js';
13
16
  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 }) {
17
+ export function AppContent({ airPlayDevices, airPlayCode, 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 }) {
15
18
  if (layout.compact) {
16
19
  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" })] }));
17
20
  }
@@ -42,5 +45,11 @@ export function AppContent({ airPlayDevices, backends, countryFilter, diagnostic
42
45
  if (screen === 'settings') {
43
46
  return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
44
47
  }
48
+ if (screen === 'airplay-settings') {
49
+ return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth }));
50
+ }
51
+ if (screen === 'airplay-code') {
52
+ return (_jsx(AirPlayCodeScreen, { code: airPlayCode, playback: playback, selectedDevice: selectedAirPlayDevice(library.settings, airPlayDevices), theme: theme, width: frameWidth }));
53
+ }
45
54
  return _jsx(Text, { children: "Unknown screen." });
46
55
  }
@@ -0,0 +1,56 @@
1
+ export function isAirPlayBackendAvailable(backends) {
2
+ return backends.includes('airplay');
3
+ }
4
+ export function selectedAirPlayDevice(settings, devices) {
5
+ return devices.find(device => device.id === settings.preferredAirPlayDevice);
6
+ }
7
+ export function airPlayReceiverSettingValue(settings, devices, backends) {
8
+ const device = selectedAirPlayDevice(settings, devices);
9
+ const count = airPlayDeviceCountLabel(devices.length);
10
+ const playbackStatus = isAirPlayBackendAvailable(backends) ? undefined : 'playback unavailable';
11
+ if (device) {
12
+ return [device.name, device.local ? 'this Mac' : playbackStatus ?? count].join(' · ');
13
+ }
14
+ if (settings.preferredAirPlayDevice) {
15
+ return ['Saved receiver missing', playbackStatus ?? count].join(' · ');
16
+ }
17
+ if (devices.length > 0) {
18
+ return ['Choose receiver...', playbackStatus ?? count].join(' · ');
19
+ }
20
+ return playbackStatus ? `No receivers found · ${playbackStatus}` : 'No receivers found';
21
+ }
22
+ export function airPlayAvailability(backends, devices) {
23
+ if (!isAirPlayBackendAvailable(backends)) {
24
+ const detail = devices.length > 0
25
+ ? 'RadioCLI can see receivers, but this install cannot start AirPlay yet. Run radiocli doctor.'
26
+ : 'AirPlay is not ready on this install. Run radiocli doctor.';
27
+ return {
28
+ ready: false,
29
+ label: 'Not ready',
30
+ detail
31
+ };
32
+ }
33
+ if (devices.length === 0) {
34
+ return {
35
+ ready: true,
36
+ label: 'Ready',
37
+ detail: 'AirPlay is installed; no receivers are visible on this network.'
38
+ };
39
+ }
40
+ return {
41
+ ready: true,
42
+ label: 'Ready',
43
+ detail: `${airPlayDeviceCountLabel(devices.length)} visible on this network.`
44
+ };
45
+ }
46
+ export function airPlayDeviceDetail(device) {
47
+ if (device.local) {
48
+ return `${device.host}:${device.port} · this Mac · use local output`;
49
+ }
50
+ const security = device.requiresPassword ? 'code shown on receiver' : 'no code';
51
+ const protocol = device.airplay2 ? 'AirPlay 2' : 'AirPlay';
52
+ return `${device.host}:${device.port} · ${protocol} · ${security}`;
53
+ }
54
+ function airPlayDeviceCountLabel(count) {
55
+ return `${count} receiver${count === 1 ? '' : 's'}`;
56
+ }
@@ -1,3 +1,4 @@
1
+ import { isPlaybackOutputError } from '../player/player-controller.js';
1
2
  const emptyMediaKeyBindings = {
2
3
  previous: [],
3
4
  playPause: [],
@@ -92,20 +93,6 @@ export function nextPlaybackBackend(current) {
92
93
  const index = playbackBackendOptions.indexOf(current);
93
94
  return playbackBackendOptions[(index + 1) % playbackBackendOptions.length] ?? 'auto';
94
95
  }
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
- }
109
96
  export function moveExploreCursor(cursor, direction, fast = false) {
110
97
  const latitudeStep = fast ? 6 : 1;
111
98
  const longitudeStep = fast ? 12 : 2;
@@ -152,8 +139,17 @@ export function favoriteTarget(screen, selectedStation, playingStation) {
152
139
  }
153
140
  return playingStation;
154
141
  }
142
+ export function shouldSkipAfterTuneError(error, skipBrokenStreams, nextStation) {
143
+ return Boolean(skipBrokenStreams && nextStation && !isPlaybackOutputError(error));
144
+ }
155
145
  export function activeTabForScreen(screen) {
156
- return screen === 'stations' || screen === 'map' ? 'countries' : screen;
146
+ if (screen === 'stations' || screen === 'map') {
147
+ return 'countries';
148
+ }
149
+ if (screen === 'airplay-settings' || screen === 'airplay-code') {
150
+ return 'settings';
151
+ }
152
+ return screen;
157
153
  }
158
154
  export function stationContextKeyForScreen(screen) {
159
155
  if (screen === 'explore' ||
@@ -0,0 +1,42 @@
1
+ import { playbackBackendLabel } from '../player/backend-install.js';
2
+ export function audioOutputLabel(output) {
3
+ if (output === 'auto') {
4
+ return 'Automatic';
5
+ }
6
+ if (output === 'mpv') {
7
+ return 'This device (mpv)';
8
+ }
9
+ if (output === 'ffplay') {
10
+ return 'This device (ffplay fallback)';
11
+ }
12
+ if (output === 'airplay') {
13
+ return 'AirPlay';
14
+ }
15
+ if (!output || output === 'none') {
16
+ return 'No output';
17
+ }
18
+ return playbackBackendLabel(output);
19
+ }
20
+ export function resolvedAudioOutput(output, backends) {
21
+ if (output !== 'auto') {
22
+ return backends.includes(output) ? output : null;
23
+ }
24
+ for (const backend of ['mpv', 'ffplay']) {
25
+ if (backends.includes(backend)) {
26
+ return backend;
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ export function audioOutputSettingValue(settings, diagnostics, backends) {
32
+ const selected = settings.preferredBackend;
33
+ const selectedLabel = audioOutputLabel(selected);
34
+ const resolved = resolvedAudioOutput(selected, backends);
35
+ if (!resolved) {
36
+ return `${selectedLabel} · unavailable`;
37
+ }
38
+ if (diagnostics.active && diagnostics.backend !== 'none' && diagnostics.backend !== resolved) {
39
+ return `${selectedLabel} · currently ${audioOutputLabel(diagnostics.backend)}`;
40
+ }
41
+ return selectedLabel;
42
+ }
@@ -1,5 +1,5 @@
1
1
  import { mediaActionLabel } from './app-state.js';
2
- export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, playbackBackend, 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
  }
@@ -42,7 +42,15 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
42
42
  return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home';
43
43
  }
44
44
  if (screen === 'settings') {
45
- return 'Enter change selected · g Radio Garden · l location · x skip · o backend · a AirPlay · 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';
46
54
  }
47
55
  if (screen === 'stats') {
48
56
  return 'b home';
@@ -6,15 +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',
17
- 'Cycle AirPlay target',
16
+ 'Audio output',
17
+ 'AirPlay receiver',
18
18
  'Volume up',
19
19
  'Volume down',
20
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
+ }