@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/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, canEnterAirPlayCode, playbackBackend, screen }) {
|
|
3
3
|
if (capturingTransportAction) {
|
|
4
4
|
return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
|
|
5
5
|
}
|
|
@@ -33,10 +33,24 @@ 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
|
|
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';
|
|
40
54
|
}
|
|
41
55
|
if (screen === 'stats') {
|
|
42
56
|
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
|
@@ -6,14 +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
|
-
'
|
|
16
|
+
'Audio output',
|
|
17
|
+
'AirPlay receiver',
|
|
17
18
|
'Volume up',
|
|
18
19
|
'Volume down',
|
|
19
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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { airPlayAvailability, airPlayDeviceDetail, selectedAirPlayDevice } from '../airplay-settings.js';
|
|
4
|
+
import { audioOutputLabel } from '../audio-output.js';
|
|
5
|
+
import { Menu, Pointer } from '../components/Menu.js';
|
|
6
|
+
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
7
|
+
import { truncate } from '../format.js';
|
|
8
|
+
import { textDim, themeAccent } from '../theme.js';
|
|
9
|
+
export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width }) {
|
|
10
|
+
const accent = themeAccent(theme);
|
|
11
|
+
const availability = airPlayAvailability(backends, devices);
|
|
12
|
+
const selectedDevice = selectedAirPlayDevice(settings, devices);
|
|
13
|
+
const selectedMissing = Boolean(settings.preferredAirPlayDevice && !selectedDevice);
|
|
14
|
+
const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
|
|
15
|
+
const lineWidth = Math.max(24, width - 4);
|
|
16
|
+
const preferredOutput = audioOutputLabel(settings.preferredBackend);
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? 'gray' : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: "gray", children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: "gray", children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: devices, selected: selected, keyFor: device => device.id, render: (device, _index, active) => {
|
|
18
|
+
const isSelected = device.id === settings.preferredAirPlayDevice;
|
|
19
|
+
const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
|
|
20
|
+
const selectedLabel = isSelected ? ' selected' : '';
|
|
21
|
+
const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
|
|
22
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color: "gray", children: [" \u00B7 ", detail] })] }));
|
|
23
|
+
} })) : (_jsx(Text, { color: "gray", children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
|
|
24
|
+
}
|
|
@@ -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,19 @@ 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 } from '../../player/backend-install.js';
|
|
9
|
+
import { airPlayReceiverSettingValue } from '../airplay-settings.js';
|
|
10
|
+
import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
|
|
11
|
+
export function SettingsScreen({ selected, settings, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width }) {
|
|
9
12
|
const accent = themeAccent(theme);
|
|
10
13
|
const lineWidth = Math.max(32, width - 4);
|
|
11
14
|
const health = Object.entries(providerHealth);
|
|
12
15
|
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);
|
|
16
|
+
const value = settingValue(item, settings, diagnostics, backends, airPlayDevices);
|
|
14
17
|
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: ["
|
|
18
|
+
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Output: ", _jsx(Text, { color: accent, children: audioOutputLabel(playback.backend) }), " / ", playback.state, " \u00B7", ' ', "Selected: ", _jsx(Text, { color: accent, children: audioOutputLabel(settings.preferredBackend) }), " \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
19
|
}
|
|
17
|
-
function settingValue(item, settings, diagnostics, backends) {
|
|
20
|
+
function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
|
|
18
21
|
switch (item) {
|
|
19
22
|
case 'Cycle display color':
|
|
20
23
|
return settings.theme;
|
|
@@ -24,9 +27,14 @@ function settingValue(item, settings, diagnostics, backends) {
|
|
|
24
27
|
return settings.enableRadioGarden ? 'on' : 'off';
|
|
25
28
|
case 'Toggle nearby location lookup':
|
|
26
29
|
return settings.enableNearbyLocation ? 'on' : 'off';
|
|
27
|
-
case '
|
|
28
|
-
return
|
|
30
|
+
case 'Audio output':
|
|
31
|
+
return audioOutputSettingValue(settings, diagnostics, backends);
|
|
32
|
+
case 'AirPlay receiver':
|
|
33
|
+
return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
|
|
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,
|
|
6
|
+
export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, 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') {
|
|
@@ -148,6 +149,21 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
148
149
|
}
|
|
149
150
|
return;
|
|
150
151
|
}
|
|
152
|
+
if (screen === 'airplay-code') {
|
|
153
|
+
if (key.return) {
|
|
154
|
+
submitAirPlayCode(airPlayCode);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (key.escape) {
|
|
158
|
+
setAirPlayCode('');
|
|
159
|
+
go('airplay-settings');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (isEditableInput(input, key)) {
|
|
163
|
+
setAirPlayCode(value => applyTextInput(value, input, key).slice(0, 64));
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
151
167
|
if (screen === 'explore') {
|
|
152
168
|
const exploreMove = exploreMoveForInput(input);
|
|
153
169
|
if (exploreMove) {
|
|
@@ -199,7 +215,15 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
199
215
|
return;
|
|
200
216
|
}
|
|
201
217
|
if (input === 'o') {
|
|
202
|
-
|
|
218
|
+
cycleAudioOutput();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (input === 'a' && screen === 'settings') {
|
|
222
|
+
openAirPlaySettings();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (input === 'c' && screen === 'airplay-settings' && canEnterAirPlayCode) {
|
|
226
|
+
openAirPlayCode();
|
|
203
227
|
return;
|
|
204
228
|
}
|
|
205
229
|
if (input === 'g') {
|
|
@@ -214,6 +238,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
214
238
|
toggleSkipBrokenStreams();
|
|
215
239
|
return;
|
|
216
240
|
}
|
|
241
|
+
if (input === 'r' && screen === 'airplay-settings') {
|
|
242
|
+
refreshAirPlayTargets();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
217
245
|
if (input === 'r') {
|
|
218
246
|
refreshProviderHealth();
|
|
219
247
|
setMessage('Provider health refreshed.');
|
|
@@ -244,6 +272,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
244
272
|
}
|
|
245
273
|
return;
|
|
246
274
|
}
|
|
275
|
+
if ((input === 'b' || key.escape) && screen === 'airplay-settings') {
|
|
276
|
+
go('settings');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
247
279
|
if (input === 'b' || key.escape) {
|
|
248
280
|
go('home');
|
|
249
281
|
return;
|
|
@@ -270,7 +302,7 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
270
302
|
return;
|
|
271
303
|
}
|
|
272
304
|
if (input === ' ') {
|
|
273
|
-
|
|
305
|
+
togglePause();
|
|
274
306
|
return;
|
|
275
307
|
}
|
|
276
308
|
if (input === 'n' && screen === 'now-playing') {
|
|
@@ -326,8 +358,11 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
326
358
|
else if (item === 'Toggle nearby location lookup') {
|
|
327
359
|
toggleNearbyLocation();
|
|
328
360
|
}
|
|
329
|
-
else if (item === '
|
|
330
|
-
|
|
361
|
+
else if (item === 'Audio output') {
|
|
362
|
+
cycleAudioOutput();
|
|
363
|
+
}
|
|
364
|
+
else if (item === 'AirPlay receiver') {
|
|
365
|
+
openAirPlaySettings();
|
|
331
366
|
}
|
|
332
367
|
else if (item === 'Volume up') {
|
|
333
368
|
adjustVolume(5);
|
|
@@ -359,6 +394,10 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
|
|
|
359
394
|
}
|
|
360
395
|
return;
|
|
361
396
|
}
|
|
397
|
+
if (screen === 'airplay-settings') {
|
|
398
|
+
selectAirPlayDeviceAt(selected);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
362
401
|
if (screen === 'map') {
|
|
363
402
|
const country = filteredCountries[selected];
|
|
364
403
|
if (country) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
|
|
3
|
-
export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
|
|
3
|
+
export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateSettings }) {
|
|
4
4
|
return useCallback(async (rawCommand) => {
|
|
5
5
|
const trimmed = rawCommand.trim();
|
|
6
6
|
if (!trimmed) {
|
|
@@ -96,6 +96,21 @@ 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
|
+
go('airplay-code');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const result = player.submitAirPlayPasscode(value.trim());
|
|
105
|
+
if (result.message) {
|
|
106
|
+
setMessage(result.message);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (name === 'airplay' || name === 'airplay-settings') {
|
|
111
|
+
openAirPlaySettings();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
99
114
|
if (name === 'sleep') {
|
|
100
115
|
const minutes = Number(value);
|
|
101
116
|
setSleepUntil(Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : null);
|
|
@@ -132,6 +147,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
132
147
|
countries,
|
|
133
148
|
go,
|
|
134
149
|
loadCountry,
|
|
150
|
+
openAirPlaySettings,
|
|
135
151
|
openLibrary,
|
|
136
152
|
player,
|
|
137
153
|
playingStation,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciphore/radiocli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -66,9 +66,13 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"ink": "^7.0.5",
|
|
69
|
+
"node-airtunes2": "2.5.0",
|
|
69
70
|
"react": "^19.2.6",
|
|
70
71
|
"zod": "^4.4.3"
|
|
71
72
|
},
|
|
73
|
+
"overrides": {
|
|
74
|
+
"protobufjs": "^7.5.8"
|
|
75
|
+
},
|
|
72
76
|
"devDependencies": {
|
|
73
77
|
"@types/node": "^25.9.1",
|
|
74
78
|
"@types/react": "^19.2.15",
|