@ciphore/radiocli 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -3
- package/CONTRIBUTING.md +5 -2
- package/README.md +43 -10
- package/dist/player/airplay-discovery.js +141 -0
- package/dist/player/airplay-sender-health.js +87 -0
- package/dist/player/airplay-worker-protocol.js +129 -0
- package/dist/player/airplay-worker.js +178 -0
- package/dist/player/backend-install.js +69 -4
- package/dist/player/player-controller.js +229 -19
- package/dist/storage/store.js +2 -1
- package/dist/ui/App.js +44 -11
- package/dist/ui/AppContent.js +4 -3
- package/dist/ui/app-state.js +19 -0
- package/dist/ui/page-footer.js +8 -2
- package/dist/ui/playback-footer.js +7 -0
- package/dist/ui/screen-items.js +1 -0
- package/dist/ui/screens/HomeScreen.js +2 -1
- package/dist/ui/screens/SettingsScreen.js +13 -5
- package/dist/ui/use-app-input.js +12 -4
- package/dist/ui/use-command-executor.js +8 -0
- package/package.json +1 -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,7 @@ import { pageFooterText } from './page-footer.js';
|
|
|
17
17
|
import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell } from './terminal-mouse.js';
|
|
18
18
|
import { useAppInput } from './use-app-input.js';
|
|
19
19
|
import { useCommandExecutor } from './use-command-executor.js';
|
|
20
|
-
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
20
|
+
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextAirPlayDeviceId, nextPlaybackBackend, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
21
21
|
const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
|
|
22
22
|
const LIVE_RECEIVER_PULSE_MS = 80;
|
|
23
23
|
const AMBIENT_RECEIVER_PULSE_MS = 140;
|
|
@@ -35,6 +35,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
35
35
|
const player = useMemo(() => new PlayerController(() => settingsRef.current), []);
|
|
36
36
|
const [playback, setPlayback] = useState(() => player.getState());
|
|
37
37
|
const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends());
|
|
38
|
+
const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
|
|
38
39
|
const [screen, setScreen] = useState('home');
|
|
39
40
|
const [selected, setSelected] = useState(0);
|
|
40
41
|
const [message, setMessage] = useState(null);
|
|
@@ -214,6 +215,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
214
215
|
useEffect(() => {
|
|
215
216
|
const backends = player.refreshDetectedBackends();
|
|
216
217
|
setAvailableBackends(backends);
|
|
218
|
+
void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
|
|
217
219
|
if (backends.length === 0) {
|
|
218
220
|
setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
|
|
219
221
|
}
|
|
@@ -503,17 +505,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
503
505
|
setLibrary(store.toggleFavorite(station));
|
|
504
506
|
setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
|
|
505
507
|
}, [store]);
|
|
508
|
+
const showControlResult = useCallback((result) => {
|
|
509
|
+
if (!result.ok && result.message) {
|
|
510
|
+
setMessage(result.message);
|
|
511
|
+
}
|
|
512
|
+
}, []);
|
|
506
513
|
const setVolume = useCallback((volume) => {
|
|
507
514
|
const clamped = clampVolume(volume);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
515
|
+
void player.setVolume(clamped).then(result => {
|
|
516
|
+
if (result.ok) {
|
|
517
|
+
updateSettings({ volume: clamped });
|
|
518
|
+
}
|
|
519
|
+
showControlResult(result);
|
|
520
|
+
});
|
|
521
|
+
}, [player, showControlResult, updateSettings]);
|
|
511
522
|
const adjustVolume = useCallback((delta) => {
|
|
512
523
|
setVolume((player.getState().volume || library.settings.volume) + delta);
|
|
513
524
|
}, [library.settings.volume, player, setVolume]);
|
|
514
525
|
const toggleMute = useCallback(() => {
|
|
515
|
-
void player.toggleMute();
|
|
516
|
-
}, [player]);
|
|
526
|
+
void player.toggleMute().then(showControlResult);
|
|
527
|
+
}, [player, showControlResult]);
|
|
528
|
+
const togglePause = useCallback(() => {
|
|
529
|
+
void player.togglePause().then(showControlResult);
|
|
530
|
+
}, [player, showControlResult]);
|
|
517
531
|
const cycleDisplayColor = useCallback(() => {
|
|
518
532
|
const theme = nextTheme(settingsRef.current.theme);
|
|
519
533
|
updateSettings({ theme });
|
|
@@ -536,11 +550,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
536
550
|
setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
|
|
537
551
|
}, [updateSettings]);
|
|
538
552
|
const cyclePlaybackBackend = useCallback(() => {
|
|
539
|
-
const
|
|
540
|
-
const preferredBackend = current === 'auto' ? 'mpv' : current === 'mpv' ? 'ffplay' : 'auto';
|
|
553
|
+
const preferredBackend = nextPlaybackBackend(settingsRef.current.preferredBackend);
|
|
541
554
|
updateSettings({ preferredBackend });
|
|
542
555
|
setMessage(`Playback backend: ${preferredBackend}`);
|
|
543
556
|
}, [updateSettings]);
|
|
557
|
+
const cycleAirPlayTarget = useCallback(() => {
|
|
558
|
+
void player.refreshAirPlayDevices().then(devices => {
|
|
559
|
+
setAvailableAirPlayDevices(devices);
|
|
560
|
+
const preferredAirPlayDevice = nextAirPlayDeviceId(settingsRef.current.preferredAirPlayDevice, devices);
|
|
561
|
+
updateSettings({ preferredAirPlayDevice });
|
|
562
|
+
const selectedAirPlayDevice = devices.find(device => device.id === preferredAirPlayDevice);
|
|
563
|
+
setMessage(`AirPlay target: ${selectedAirPlayDevice?.name ?? 'auto'}`);
|
|
564
|
+
}).catch(() => {
|
|
565
|
+
setAvailableAirPlayDevices([]);
|
|
566
|
+
updateSettings({ preferredAirPlayDevice: undefined });
|
|
567
|
+
setMessage('No AirPlay receivers found.');
|
|
568
|
+
});
|
|
569
|
+
}, [player, updateSettings]);
|
|
544
570
|
const toggleSkipBrokenStreams = useCallback(() => {
|
|
545
571
|
const skipBrokenStreams = !settingsRef.current.skipBrokenStreams;
|
|
546
572
|
updateSettings({ skipBrokenStreams });
|
|
@@ -648,6 +674,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
648
674
|
commandMode,
|
|
649
675
|
commandText,
|
|
650
676
|
currentItemCount,
|
|
677
|
+
cycleAirPlayTarget,
|
|
651
678
|
cycleDisplayColor,
|
|
652
679
|
cyclePlaybackBackend,
|
|
653
680
|
cycleReceiverStyle,
|
|
@@ -691,6 +718,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
691
718
|
stdin,
|
|
692
719
|
toggleFavorite,
|
|
693
720
|
toggleMute,
|
|
721
|
+
togglePause,
|
|
694
722
|
toggleNearbyLocation,
|
|
695
723
|
toggleRadioGarden,
|
|
696
724
|
toggleSkipBrokenStreams
|
|
@@ -699,7 +727,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
699
727
|
return itemCountsRef.current[currentScreen] ?? 0;
|
|
700
728
|
}
|
|
701
729
|
const hasTopTabs = !layout.compact;
|
|
702
|
-
const globalFooter =
|
|
730
|
+
const globalFooter = playback.backend === 'ffplay'
|
|
731
|
+
? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · q quit'
|
|
732
|
+
: playback.backend === 'airplay'
|
|
733
|
+
? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · q quit'
|
|
734
|
+
: '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
|
|
703
735
|
const playbackFooter = playbackFooterText({
|
|
704
736
|
station: playingStation,
|
|
705
737
|
playback,
|
|
@@ -716,9 +748,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
716
748
|
commandText,
|
|
717
749
|
editingCountryFilter,
|
|
718
750
|
editingSearch,
|
|
751
|
+
playbackBackend: playback.backend,
|
|
719
752
|
screen
|
|
720
753
|
});
|
|
721
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: appBackground, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: appBackground, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playback.backend
|
|
754
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: appBackground, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: appBackground, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: appBackground, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), message ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: themeAccent(theme), children: message }) })) : null] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: panelBackground, children: [playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : 'gray', children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }));
|
|
722
755
|
}
|
|
723
756
|
function buildLibraryStations(library) {
|
|
724
757
|
const stations = [];
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -10,9 +10,10 @@ import { StationScreen } from './screens/StationScreen.js';
|
|
|
10
10
|
import { NowPlayingScreen } from './screens/NowPlayingScreen.js';
|
|
11
11
|
import { StatsScreen } from './screens/StatsScreen.js';
|
|
12
12
|
import { SettingsScreen } from './screens/SettingsScreen.js';
|
|
13
|
-
|
|
13
|
+
import { playbackBackendLabel } from '../player/backend-install.js';
|
|
14
|
+
export function AppContent({ airPlayDevices, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme }) {
|
|
14
15
|
if (layout.compact) {
|
|
15
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: "gray", children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: "gray", children: ["Playback: ", playback.state, " \u00B7 ", playback.backend] }), _jsx(Text, { color: "gray", children: "q quit \u00B7 Ctrl+C always exits" })] }));
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: "gray", children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: "gray", children: ["Playback: ", playback.state, " \u00B7 ", playbackBackendLabel(playback.backend)] }), _jsx(Text, { color: "gray", children: "q quit \u00B7 Ctrl+C always exits" })] }));
|
|
16
17
|
}
|
|
17
18
|
if (screen === 'home') {
|
|
18
19
|
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library, playback: playback });
|
|
@@ -39,7 +40,7 @@ export function AppContent({ backends, countryFilter, diagnostics, displayStatio
|
|
|
39
40
|
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
40
41
|
}
|
|
41
42
|
if (screen === 'settings') {
|
|
42
|
-
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
|
|
43
|
+
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
|
|
43
44
|
}
|
|
44
45
|
return _jsx(Text, { children: "Unknown screen." });
|
|
45
46
|
}
|
package/dist/ui/app-state.js
CHANGED
|
@@ -5,6 +5,7 @@ const emptyMediaKeyBindings = {
|
|
|
5
5
|
};
|
|
6
6
|
const mediaTransportActions = ['previous', 'playPause', 'next'];
|
|
7
7
|
const sleepTimerOptions = [null, 15, 30, 60];
|
|
8
|
+
const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'airplay'];
|
|
8
9
|
export const defaultExploreCursor = {
|
|
9
10
|
latitude: 48.8566,
|
|
10
11
|
longitude: 2.3522
|
|
@@ -87,6 +88,24 @@ export function nextSleepTimerMinutes(currentMinutes) {
|
|
|
87
88
|
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % sleepTimerOptions.length : 0;
|
|
88
89
|
return sleepTimerOptions[nextIndex] ?? null;
|
|
89
90
|
}
|
|
91
|
+
export function nextPlaybackBackend(current) {
|
|
92
|
+
const index = playbackBackendOptions.indexOf(current);
|
|
93
|
+
return playbackBackendOptions[(index + 1) % playbackBackendOptions.length] ?? 'auto';
|
|
94
|
+
}
|
|
95
|
+
export function nextAirPlayDeviceId(current, devices) {
|
|
96
|
+
if (devices.length === 0) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const ids = devices.map(device => device.id);
|
|
100
|
+
if (!current) {
|
|
101
|
+
return ids[0];
|
|
102
|
+
}
|
|
103
|
+
const index = ids.indexOf(current);
|
|
104
|
+
if (index === -1 || index === ids.length - 1) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return ids[index + 1];
|
|
108
|
+
}
|
|
90
109
|
export function moveExploreCursor(cursor, direction, fast = false) {
|
|
91
110
|
const latitudeStep = fast ? 6 : 1;
|
|
92
111
|
const longitudeStep = fast ? 12 : 2;
|
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, screen }) {
|
|
2
|
+
export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, playbackBackend, screen }) {
|
|
3
3
|
if (capturingTransportAction) {
|
|
4
4
|
return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
|
|
5
5
|
}
|
|
@@ -33,10 +33,16 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
33
33
|
return '↑/↓ or n/p move · Enter tune · f favorite · [/] page · b home';
|
|
34
34
|
}
|
|
35
35
|
if (screen === 'now-playing') {
|
|
36
|
+
if (playbackBackend === 'ffplay') {
|
|
37
|
+
return 'ffplay fallback: install mpv for pause/mute/media keys · f favorite · s sleep · d diagnostics · b home';
|
|
38
|
+
}
|
|
39
|
+
if (playbackBackend === 'airplay') {
|
|
40
|
+
return 'AirPlay: +/- volume · m mute · f favorite · s sleep · d diagnostics · b home';
|
|
41
|
+
}
|
|
36
42
|
return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home';
|
|
37
43
|
}
|
|
38
44
|
if (screen === 'settings') {
|
|
39
|
-
return 'Enter change selected · g Radio Garden · l location · x skip · o backend · r health · b home';
|
|
45
|
+
return 'Enter change selected · g Radio Garden · l location · x skip · o backend · a AirPlay · r health · b home';
|
|
40
46
|
}
|
|
41
47
|
if (screen === 'stats') {
|
|
42
48
|
return 'b home';
|
|
@@ -28,6 +28,7 @@ export function playbackFooterText({ station, playback, metadata, sleepLabel, wi
|
|
|
28
28
|
statePrefix(playback.state),
|
|
29
29
|
nameLabel,
|
|
30
30
|
loading ? 'buffering…' : trackLabel(metadata, stationName),
|
|
31
|
+
outputLabel(playback),
|
|
31
32
|
playback.muted ? 'muted' : `vol ${playback.volume}`,
|
|
32
33
|
sleepLabel !== 'Sleep off' ? sleepLabel : undefined
|
|
33
34
|
].filter(Boolean);
|
|
@@ -39,6 +40,12 @@ function statePrefix(state) {
|
|
|
39
40
|
}
|
|
40
41
|
return undefined;
|
|
41
42
|
}
|
|
43
|
+
function outputLabel(playback) {
|
|
44
|
+
if (playback.backend !== 'airplay') {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return playback.airPlayDeviceName ? `AirPlay ${playback.airPlayDeviceName}` : 'AirPlay';
|
|
48
|
+
}
|
|
42
49
|
function trackLabel(metadata, stationName) {
|
|
43
50
|
const title = metadata?.title?.trim();
|
|
44
51
|
if (!title || title.toLowerCase() === stationName.toLowerCase()) {
|
package/dist/ui/screen-items.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Logo } from '../components/Logo.js';
|
|
|
4
4
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
5
5
|
import { themeAccent } from '../theme.js';
|
|
6
6
|
import { homeItems } from '../screen-items.js';
|
|
7
|
+
import { playbackBackendLabel } from '../../player/backend-install.js';
|
|
7
8
|
export function HomeScreen({ selected, theme, library, playback }) {
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playback.backend] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playbackBackendLabel(playback.backend)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
|
|
9
10
|
}
|
|
@@ -5,16 +5,17 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
|
5
5
|
import { truncate } from '../format.js';
|
|
6
6
|
import { settingsItems } from '../screen-items.js';
|
|
7
7
|
import { themeAccent } from '../theme.js';
|
|
8
|
-
|
|
8
|
+
import { playbackBackendCapabilities, playbackBackendLabel } from '../../player/backend-install.js';
|
|
9
|
+
export function SettingsScreen({ selected, settings, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width }) {
|
|
9
10
|
const accent = themeAccent(theme);
|
|
10
11
|
const lineWidth = Math.max(32, width - 4);
|
|
11
12
|
const health = Object.entries(providerHealth);
|
|
12
13
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: settingsItems, selected: selected, keyFor: item => item, render: (item, _index, active) => {
|
|
13
|
-
const value = settingValue(item, settings, diagnostics, backends);
|
|
14
|
+
const value = settingValue(item, settings, diagnostics, backends, airPlayDevices);
|
|
14
15
|
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: item }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
|
|
15
|
-
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Player: ", _jsx(Text, { color: accent, children: playback.backend
|
|
16
|
+
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Player: ", _jsx(Text, { color: accent, children: playbackBackendLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: "gray", children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: "gray", children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: "gray", children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
|
|
16
17
|
}
|
|
17
|
-
function settingValue(item, settings, diagnostics, backends) {
|
|
18
|
+
function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
|
|
18
19
|
switch (item) {
|
|
19
20
|
case 'Cycle display color':
|
|
20
21
|
return settings.theme;
|
|
@@ -25,8 +26,15 @@ function settingValue(item, settings, diagnostics, backends) {
|
|
|
25
26
|
case 'Toggle nearby location lookup':
|
|
26
27
|
return settings.enableNearbyLocation ? 'on' : 'off';
|
|
27
28
|
case 'Cycle playback backend':
|
|
28
|
-
return `${settings.preferredBackend} · available ${backends.length ? backends.join(', ') : 'none'}`;
|
|
29
|
+
return `${settings.preferredBackend} · available ${backends.length ? backends.map(playbackBackendLabel).join(', ') : 'none'}`;
|
|
30
|
+
case 'Cycle AirPlay target': {
|
|
31
|
+
const device = airPlayDevices.find(candidate => candidate.id === settings.preferredAirPlayDevice);
|
|
32
|
+
return `${device?.name ?? settings.preferredAirPlayDevice ?? 'auto'} · ${airPlayDevices.length || 'no'} found`;
|
|
33
|
+
}
|
|
29
34
|
case 'Mute or unmute':
|
|
35
|
+
if (diagnostics.backend === 'ffplay' && !playbackBackendCapabilities(diagnostics.backend).supportsMute) {
|
|
36
|
+
return 'requires mpv';
|
|
37
|
+
}
|
|
30
38
|
return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
|
|
31
39
|
case 'Toggle skip broken streams':
|
|
32
40
|
return settings.skipBrokenStreams ? 'on' : 'off';
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useInput } from 'ink';
|
|
|
3
3
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
4
4
|
import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
|
|
5
5
|
import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
|
|
6
|
-
export function useAppInput({ adjustVolume, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, setCapturingTransportAction, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
|
|
6
|
+
export function useAppInput({ adjustVolume, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleAirPlayTarget, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, setCapturingTransportAction, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
|
|
7
7
|
useEffect(() => {
|
|
8
8
|
const onData = (data) => {
|
|
9
9
|
const rawInput = String(data);
|
|
@@ -43,7 +43,7 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
43
43
|
}
|
|
44
44
|
else if (action === 'playPause') {
|
|
45
45
|
lastRawTransportAtRef.current = Date.now();
|
|
46
|
-
|
|
46
|
+
togglePause();
|
|
47
47
|
}
|
|
48
48
|
};
|
|
49
49
|
stdin.on('data', onData);
|
|
@@ -64,7 +64,8 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
64
64
|
setCapturingTransportAction,
|
|
65
65
|
setMessage,
|
|
66
66
|
settingsRef,
|
|
67
|
-
stdin
|
|
67
|
+
stdin,
|
|
68
|
+
togglePause
|
|
68
69
|
]);
|
|
69
70
|
useInput((input, key) => {
|
|
70
71
|
if (key.ctrl && input === 'c') {
|
|
@@ -202,6 +203,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
202
203
|
cyclePlaybackBackend();
|
|
203
204
|
return;
|
|
204
205
|
}
|
|
206
|
+
if (input === 'a' && screen === 'settings') {
|
|
207
|
+
cycleAirPlayTarget();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
205
210
|
if (input === 'g') {
|
|
206
211
|
toggleRadioGarden();
|
|
207
212
|
return;
|
|
@@ -270,7 +275,7 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
270
275
|
return;
|
|
271
276
|
}
|
|
272
277
|
if (input === ' ') {
|
|
273
|
-
|
|
278
|
+
togglePause();
|
|
274
279
|
return;
|
|
275
280
|
}
|
|
276
281
|
if (input === 'n' && screen === 'now-playing') {
|
|
@@ -329,6 +334,9 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
329
334
|
else if (item === 'Cycle playback backend') {
|
|
330
335
|
cyclePlaybackBackend();
|
|
331
336
|
}
|
|
337
|
+
else if (item === 'Cycle AirPlay target') {
|
|
338
|
+
cycleAirPlayTarget();
|
|
339
|
+
}
|
|
332
340
|
else if (item === 'Volume up') {
|
|
333
341
|
adjustVolume(5);
|
|
334
342
|
}
|
|
@@ -96,6 +96,14 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
96
96
|
setMessage(`Learned keys: prev ${mediaKeys.previous.length}, play ${mediaKeys.playPause.length}, next ${mediaKeys.next.length}. Use :keys reset to clear.`);
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
|
+
if (name === 'airplay-code' || name === 'airplay-passcode') {
|
|
100
|
+
if (!value.trim()) {
|
|
101
|
+
setMessage('Usage: :airplay-code <code>');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
player.submitAirPlayPasscode(value.trim());
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
99
107
|
if (name === 'sleep') {
|
|
100
108
|
const minutes = Number(value);
|
|
101
109
|
setSleepUntil(Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : null);
|