@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.
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,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, 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;
@@ -35,6 +38,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
35
38
  const player = useMemo(() => new PlayerController(() => settingsRef.current), []);
36
39
  const [playback, setPlayback] = useState(() => player.getState());
37
40
  const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends());
41
+ const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
38
42
  const [screen, setScreen] = useState('home');
39
43
  const [selected, setSelected] = useState(0);
40
44
  const [message, setMessage] = useState(null);
@@ -55,6 +59,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
55
59
  const [spinnerFrame, setSpinnerFrame] = useState(0);
56
60
  const [commandMode, setCommandMode] = useState(false);
57
61
  const [commandText, setCommandText] = useState('');
62
+ const [airPlayCode, setAirPlayCode] = useState('');
58
63
  const [filters, setFilters] = useState({ codec: null, language: null, minBitrate: null });
59
64
  const [sleepUntil, setSleepUntil] = useState(null);
60
65
  const [showDiagnostics, setShowDiagnostics] = useState(false);
@@ -117,6 +122,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
117
122
  'now-playing': 1,
118
123
  library: 0,
119
124
  stats: 1,
125
+ 'airplay-settings': 0,
126
+ 'airplay-code': 1,
120
127
  settings: settingsItems.length
121
128
  });
122
129
  itemCountsRef.current = {
@@ -130,12 +137,17 @@ export function App({ store: providedStore, providers: providedProviders }) {
130
137
  'now-playing': 1,
131
138
  library: stationCounts.library,
132
139
  stats: 1,
140
+ 'airplay-settings': availableAirPlayDevices.length,
141
+ 'airplay-code': 1,
133
142
  settings: settingsItems.length
134
143
  };
135
144
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
136
145
  displayStationsRef.current = displayStations;
137
146
  const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
138
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);
139
151
  const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
140
152
  const frameWidth = Math.max(40, layout.columns - 2);
141
153
  useEffect(() => player.onChange(setPlayback), [player]);
@@ -192,7 +204,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
192
204
  }, []);
193
205
  useEffect(() => {
194
206
  setSelected(value => clamp(value, currentItemCount(screen) - 1));
195
- }, [displayStations.length, filteredCountries.length, screen]);
207
+ }, [availableAirPlayDevices.length, displayStations.length, filteredCountries.length, screen]);
196
208
  useEffect(() => {
197
209
  if (!sleepUntil) {
198
210
  return;
@@ -214,6 +226,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
214
226
  useEffect(() => {
215
227
  const backends = player.refreshDetectedBackends();
216
228
  setAvailableBackends(backends);
229
+ void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
217
230
  if (backends.length === 0) {
218
231
  setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
219
232
  }
@@ -235,6 +248,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
235
248
  setLibrary(nextLibrary);
236
249
  return nextLibrary;
237
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]);
238
263
  const updateMediaKeys = useCallback((mediaKeys) => {
239
264
  updateSettings({ mediaKeys: normalizeMediaKeyBindings(mediaKeys) });
240
265
  }, [updateSettings]);
@@ -262,6 +287,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
262
287
  setMessage(null);
263
288
  }
264
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]);
265
299
  const shutdown = useCallback(() => {
266
300
  store.finishActiveListeningSession();
267
301
  player.stop().finally(exit);
@@ -473,7 +507,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
473
507
  setPlayingStation(station);
474
508
  playbackQueueRef.current = queue;
475
509
  store.startListeningSession(station);
476
- 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);
477
520
  if (options.openNowPlaying) {
478
521
  go('now-playing');
479
522
  }
@@ -484,7 +527,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
484
527
  const currentList = queue.stations;
485
528
  const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
486
529
  const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
487
- if (settingsRef.current.skipBrokenStreams && nextStation) {
530
+ if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
488
531
  setMessage(`${message} Skipping to ${nextStation.name}.`);
489
532
  rememberQueueSelection(queue, currentIndex + 1);
490
533
  setTimeout(() => playStationRef.current(nextStation, { ...options, queue }), 250);
@@ -492,7 +535,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
492
535
  }
493
536
  setMessage(message);
494
537
  }
495
- }, [go, player, providers, queueFromCurrentList, rememberQueueSelection, store]);
538
+ }, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
496
539
  playStationRef.current = playStation;
497
540
  const toggleFavorite = useCallback((station) => {
498
541
  if (!station) {
@@ -503,17 +546,39 @@ export function App({ store: providedStore, providers: providedProviders }) {
503
546
  setLibrary(store.toggleFavorite(station));
504
547
  setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
505
548
  }, [store]);
549
+ const showControlResult = useCallback((result) => {
550
+ if (!result.ok && result.message) {
551
+ setMessage(result.message);
552
+ }
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]);
506
564
  const setVolume = useCallback((volume) => {
507
565
  const clamped = clampVolume(volume);
508
- updateSettings({ volume: clamped });
509
- void player.setVolume(clamped);
510
- }, [player, updateSettings]);
566
+ void player.setVolume(clamped).then(result => {
567
+ if (result.ok) {
568
+ updateSettings({ volume: clamped });
569
+ }
570
+ showControlResult(result);
571
+ });
572
+ }, [player, showControlResult, updateSettings]);
511
573
  const adjustVolume = useCallback((delta) => {
512
574
  setVolume((player.getState().volume || library.settings.volume) + delta);
513
575
  }, [library.settings.volume, player, setVolume]);
514
576
  const toggleMute = useCallback(() => {
515
- void player.toggleMute();
516
- }, [player]);
577
+ void player.toggleMute().then(showControlResult);
578
+ }, [player, showControlResult]);
579
+ const togglePause = useCallback(() => {
580
+ void player.togglePause().then(showControlResult);
581
+ }, [player, showControlResult]);
517
582
  const cycleDisplayColor = useCallback(() => {
518
583
  const theme = nextTheme(settingsRef.current.theme);
519
584
  updateSettings({ theme });
@@ -535,12 +600,96 @@ export function App({ store: providedStore, providers: providedProviders }) {
535
600
  updateSettings({ enableNearbyLocation });
536
601
  setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
537
602
  }, [updateSettings]);
538
- const cyclePlaybackBackend = useCallback(() => {
539
- const current = settingsRef.current.preferredBackend;
540
- const preferredBackend = current === 'auto' ? 'mpv' : current === 'mpv' ? 'ffplay' : 'auto';
603
+ const refreshAirPlayTargets = useCallback(async (announce = true) => {
604
+ try {
605
+ const devices = await player.refreshAirPlayDevices();
606
+ setAvailableAirPlayDevices(devices);
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 {
620
+ setAvailableAirPlayDevices([]);
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);
541
679
  updateSettings({ preferredBackend });
542
- setMessage(`Playback backend: ${preferredBackend}`);
543
- }, [updateSettings]);
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]);
544
693
  const toggleSkipBrokenStreams = useCallback(() => {
545
694
  const skipBrokenStreams = !settingsRef.current.skipBrokenStreams;
546
695
  updateSettings({ skipBrokenStreams });
@@ -579,10 +728,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
579
728
  else if (target === 'library') {
580
729
  openLibrary();
581
730
  }
731
+ else if (target === 'airplay-settings') {
732
+ openAirPlaySettings();
733
+ }
582
734
  else {
583
735
  go(target);
584
736
  }
585
- }, [go, loadExplore, loadNearby, openLibrary]);
737
+ }, [go, loadExplore, loadNearby, openAirPlaySettings, openLibrary]);
586
738
  const openAdjacentTab = useCallback((direction) => {
587
739
  const active = activeTabForScreen(screen);
588
740
  const currentIndex = topTabs.findIndex(tab => tab.screen === active);
@@ -597,6 +749,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
597
749
  countries,
598
750
  go,
599
751
  loadCountry,
752
+ openAirPlaySettings,
600
753
  openLibrary,
601
754
  player,
602
755
  playingStation,
@@ -643,13 +796,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
643
796
  }, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
644
797
  useAppInput({
645
798
  adjustVolume,
799
+ airPlayCode,
800
+ canEnterAirPlayCode,
646
801
  beginLearningTransportKey,
647
802
  capturingTransportAction,
648
803
  commandMode,
649
804
  commandText,
650
805
  currentItemCount,
651
806
  cycleDisplayColor,
652
- cyclePlaybackBackend,
807
+ cycleAudioOutput,
653
808
  cycleReceiverStyle,
654
809
  cycleSleepTimer,
655
810
  editingCountryFilter,
@@ -661,6 +816,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
661
816
  lastSubmittedSearchRef,
662
817
  loadCountry,
663
818
  openAdjacentTab,
819
+ openAirPlayCode,
820
+ openAirPlaySettings,
664
821
  openScreen,
665
822
  playAdjacent,
666
823
  playStation,
@@ -669,6 +826,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
669
826
  moveExploreCursor: moveExploreMapCursor,
670
827
  moveExploreCursorToCell: moveExploreMapCursorToCell,
671
828
  refreshProviderHealth,
829
+ refreshAirPlayTargets: () => {
830
+ void refreshAirPlayTargets();
831
+ },
672
832
  resetLearnedTransportKeys,
673
833
  runSearch,
674
834
  saveLearnedTransportKey,
@@ -676,21 +836,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
676
836
  searchQuery,
677
837
  selected,
678
838
  selectedStation,
839
+ selectAirPlayDeviceAt,
679
840
  setCapturingTransportAction,
680
841
  setCommandMode,
681
842
  setCommandText,
682
843
  setCountryFilter,
844
+ setAirPlayCode,
683
845
  setEditingCountryFilter,
684
846
  setEditingSearch,
685
847
  setMessage,
686
848
  setSearchQuery,
687
849
  setSelected,
688
850
  setShowDiagnostics,
851
+ submitAirPlayCode,
689
852
  settingsRef,
690
853
  shutdown,
691
854
  stdin,
692
855
  toggleFavorite,
693
856
  toggleMute,
857
+ togglePause,
694
858
  toggleNearbyLocation,
695
859
  toggleRadioGarden,
696
860
  toggleSkipBrokenStreams
@@ -699,7 +863,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
699
863
  return itemCountsRef.current[currentScreen] ?? 0;
700
864
  }
701
865
  const hasTopTabs = !layout.compact;
702
- const globalFooter = '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
866
+ const globalFooter = playback.backend === 'ffplay'
867
+ ? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · q quit'
868
+ : playback.backend === 'airplay'
869
+ ? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · q quit'
870
+ : '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
703
871
  const playbackFooter = playbackFooterText({
704
872
  station: playingStation,
705
873
  playback,
@@ -711,14 +879,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
711
879
  spinnerFrame
712
880
  });
713
881
  const pageFooter = pageFooterText({
882
+ canEnterAirPlayCode,
714
883
  capturingTransportAction,
715
884
  commandMode,
716
885
  commandText,
717
886
  editingCountryFilter,
718
887
  editingSearch,
888
+ playbackBackend: playback.backend,
719
889
  screen
720
890
  });
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) })] })] }));
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) })] })] }));
722
892
  }
723
893
  function buildLibraryStations(library) {
724
894
  const stations = [];
@@ -765,6 +935,42 @@ function formatDistanceKm(distanceKm) {
765
935
  }
766
936
  return `${Math.round(distanceKm).toLocaleString()} km`;
767
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
+ }
768
974
  function librarySubtitle(library) {
769
975
  return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported · favorites first`;
770
976
  }
@@ -10,9 +10,13 @@ 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 { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
14
+ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
+ import { selectedAirPlayDevice } from './airplay-settings.js';
16
+ import { playbackBackendLabel } from '../player/backend-install.js';
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 }) {
14
18
  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" })] }));
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" })] }));
16
20
  }
17
21
  if (screen === 'home') {
18
22
  return _jsx(HomeScreen, { selected: selected, theme: theme, library: library, playback: playback });
@@ -39,7 +43,13 @@ export function AppContent({ backends, countryFilter, diagnostics, displayStatio
39
43
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
40
44
  }
41
45
  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 }));
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 }));
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 }));
43
53
  }
44
54
  return _jsx(Text, { children: "Unknown screen." });
45
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: [],
@@ -5,6 +6,7 @@ const emptyMediaKeyBindings = {
5
6
  };
6
7
  const mediaTransportActions = ['previous', 'playPause', 'next'];
7
8
  const sleepTimerOptions = [null, 15, 30, 60];
9
+ const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'airplay'];
8
10
  export const defaultExploreCursor = {
9
11
  latitude: 48.8566,
10
12
  longitude: 2.3522
@@ -87,6 +89,10 @@ export function nextSleepTimerMinutes(currentMinutes) {
87
89
  const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % sleepTimerOptions.length : 0;
88
90
  return sleepTimerOptions[nextIndex] ?? null;
89
91
  }
92
+ export function nextPlaybackBackend(current) {
93
+ const index = playbackBackendOptions.indexOf(current);
94
+ return playbackBackendOptions[(index + 1) % playbackBackendOptions.length] ?? 'auto';
95
+ }
90
96
  export function moveExploreCursor(cursor, direction, fast = false) {
91
97
  const latitudeStep = fast ? 6 : 1;
92
98
  const longitudeStep = fast ? 12 : 2;
@@ -133,8 +139,17 @@ export function favoriteTarget(screen, selectedStation, playingStation) {
133
139
  }
134
140
  return playingStation;
135
141
  }
142
+ export function shouldSkipAfterTuneError(error, skipBrokenStreams, nextStation) {
143
+ return Boolean(skipBrokenStreams && nextStation && !isPlaybackOutputError(error));
144
+ }
136
145
  export function activeTabForScreen(screen) {
137
- 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;
138
153
  }
139
154
  export function stationContextKeyForScreen(screen) {
140
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
+ }