@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/CHANGELOG.md +35 -3
- package/README.md +23 -27
- package/dist/player/airplay-discovery.js +34 -1
- package/dist/player/airplay-sender-health.js +19 -8
- package/dist/player/airplay-sender-patch.js +138 -0
- package/dist/player/airplay-worker-protocol.js +22 -7
- package/dist/player/airplay-worker.js +79 -39
- package/dist/player/backend-install.js +1 -1
- package/dist/player/player-controller.js +253 -36
- package/dist/storage/store.js +14 -5
- package/dist/ui/App.js +198 -25
- package/dist/ui/AppContent.js +10 -1
- package/dist/ui/airplay-settings.js +56 -0
- package/dist/ui/app-state.js +11 -15
- package/dist/ui/audio-output.js +42 -0
- package/dist/ui/page-footer.js +10 -2
- package/dist/ui/screen-items.js +3 -3
- package/dist/ui/screens/AirPlayCodeScreen.js +18 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +24 -0
- package/dist/ui/screens/SettingsScreen.js +8 -8
- package/dist/ui/use-app-input.js +38 -7
- package/dist/ui/use-command-executor.js +11 -3
- package/package.json +5 -1
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/ui/app-state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/ui/page-footer.js
CHANGED
|
@@ -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
|
|
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';
|
package/dist/ui/screen-items.js
CHANGED
|
@@ -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: '
|
|
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
|
-
'
|
|
17
|
-
'
|
|
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
|
+
}
|