@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/CHANGELOG.md +60 -3
- package/CONTRIBUTING.md +5 -2
- package/README.md +44 -15
- package/dist/player/airplay-discovery.js +174 -0
- package/dist/player/airplay-sender-health.js +98 -0
- package/dist/player/airplay-sender-patch.js +138 -0
- package/dist/player/airplay-worker-protocol.js +144 -0
- package/dist/player/airplay-worker.js +218 -0
- package/dist/player/backend-install.js +69 -4
- package/dist/player/player-controller.js +447 -20
- package/dist/storage/store.js +16 -6
- package/dist/ui/App.js +226 -20
- package/dist/ui/AppContent.js +13 -3
- package/dist/ui/airplay-settings.js +56 -0
- package/dist/ui/app-state.js +16 -1
- package/dist/ui/audio-output.js +42 -0
- package/dist/ui/page-footer.js +16 -2
- package/dist/ui/playback-footer.js +7 -0
- package/dist/ui/screen-items.js +3 -2
- package/dist/ui/screens/AirPlayCodeScreen.js +18 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +24 -0
- package/dist/ui/screens/HomeScreen.js +2 -1
- package/dist/ui/screens/SettingsScreen.js +14 -6
- package/dist/ui/use-app-input.js +46 -7
- package/dist/ui/use-command-executor.js +17 -1
- package/package.json +5 -1
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
}
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
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: [],
|
|
@@ -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
|
-
|
|
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
|
+
}
|