@ciphore/radiocli 0.2.4 → 0.2.6
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 +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -7,6 +7,7 @@ import { toAsciiSafe } from '../ascii.js';
|
|
|
7
7
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
8
8
|
import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
|
|
9
9
|
import { useReceiverPulse } from '../receiver-animation.js';
|
|
10
|
+
import { ReceiverVisualRow } from '../components/ReceiverVisualRow.js';
|
|
10
11
|
// Terminal rows are roughly twice as tall as columns are wide. One vertical
|
|
11
12
|
// row and three horizontal columns produce a closer optical inset around the
|
|
12
13
|
// highest and lowest visible text than a mechanically equal cell count.
|
|
@@ -40,18 +41,8 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
40
41
|
: station
|
|
41
42
|
? 'Waiting for ICY track metadata'
|
|
42
43
|
: infoFallback;
|
|
43
|
-
const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
|
|
44
44
|
const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children:
|
|
46
|
-
? renderSegments(row.segments)
|
|
47
|
-
: row.text }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
48
|
-
}
|
|
49
|
-
function asciifyVisualRow(row) {
|
|
50
|
-
return {
|
|
51
|
-
...row,
|
|
52
|
-
text: toAsciiSafe(row.text),
|
|
53
|
-
segments: row.segments?.map(segment => ({ ...segment, text: toAsciiSafe(segment.text) }))
|
|
54
|
-
};
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: visualRows.map((row, index) => (_jsx(ReceiverVisualRow, { row: row, width: innerWidth, ascii: ascii, backgroundColor: panelBackground }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
55
46
|
}
|
|
56
47
|
export function recentTracksForStation(history, station, limit) {
|
|
57
48
|
if (!station) {
|
|
@@ -74,13 +65,3 @@ export function receiverStationIdentity(station, width) {
|
|
|
74
65
|
location: truncate(stationLocation(station).toUpperCase(), locationWidth)
|
|
75
66
|
};
|
|
76
67
|
}
|
|
77
|
-
function renderSegments(segments) {
|
|
78
|
-
let offset = 0;
|
|
79
|
-
return segments.map(segment => {
|
|
80
|
-
// Keep identity tied to geometry, not animated color. Color-bearing keys
|
|
81
|
-
// forced React to unmount and recreate hundreds of Text nodes per frame.
|
|
82
|
-
const key = offset;
|
|
83
|
-
offset += segment.text.length;
|
|
84
|
-
return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: segment.text }, key));
|
|
85
|
-
});
|
|
86
|
-
}
|
|
@@ -4,7 +4,7 @@ import { defaultAgentControlSettings } from '../../types.js';
|
|
|
4
4
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
5
5
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
6
6
|
import { truncate } from '../format.js';
|
|
7
|
-
import { settingsGroup,
|
|
7
|
+
import { settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage } from '../screen-items.js';
|
|
8
8
|
import { textMuted, themeAccent } from '../theme.js';
|
|
9
9
|
import { playbackBackendCapabilities } from '../../player/backend-install.js';
|
|
10
10
|
import { airPlayReceiverSettingValue } from '../airplay-settings.js';
|
|
@@ -12,17 +12,17 @@ import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
|
|
|
12
12
|
import { updateStatusText } from '../../update-check.js';
|
|
13
13
|
import { useDisplay } from '../display-context.js';
|
|
14
14
|
import { toAsciiSafe } from '../ascii.js';
|
|
15
|
-
export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height, airPlaySupported = true }) {
|
|
15
|
+
export function SettingsScreen({ platform = process.platform, page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height, airPlaySupported = true }) {
|
|
16
16
|
const { ascii } = useDisplay();
|
|
17
17
|
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
18
18
|
const accent = themeAccent(theme);
|
|
19
19
|
const lineWidth = Math.max(32, width - 4);
|
|
20
|
-
const group = settingsGroup(page);
|
|
21
|
-
const pageItems = settingsItemsForPage(page);
|
|
20
|
+
const group = settingsGroup(page, airPlaySupported);
|
|
21
|
+
const pageItems = settingsItemsForPage(page, airPlaySupported);
|
|
22
22
|
const menuWindow = settingsMenuWindow(pageItems, selected, height);
|
|
23
23
|
const selectedItem = pageItems[selected];
|
|
24
24
|
const detail = page === 'root' && selectedItem !== 'Check for updates'
|
|
25
|
-
? settingsGroupsDescription(selectedItem)
|
|
25
|
+
? settingsGroupsDescription(selectedItem, airPlaySupported)
|
|
26
26
|
: settingDetail({
|
|
27
27
|
item: selectedItem,
|
|
28
28
|
appVersion,
|
|
@@ -39,8 +39,8 @@ export function SettingsScreen({ page = 'root', selected, settings, appVersion,
|
|
|
39
39
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: group ? `Settings › ${group.label}` : 'Choose a category', width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: item => item, render: (item, _index, active) => {
|
|
40
40
|
const label = settingLabel(item, updateCheck, appVersion, airPlaySupported);
|
|
41
41
|
const value = page === 'root'
|
|
42
|
-
? settingsRootValue(item)
|
|
43
|
-
: settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
|
|
42
|
+
? settingsRootValue(item, airPlaySupported)
|
|
43
|
+
: settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion, platform);
|
|
44
44
|
return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(truncate(label, labelWidth)) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: a(truncate(value ?? '', valueWidth)) }) })] }));
|
|
45
45
|
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: a(truncate(detail, lineWidth)) })] })] }));
|
|
46
46
|
}
|
|
@@ -56,17 +56,17 @@ function settingsMenuWindow(items, selected, height) {
|
|
|
56
56
|
selectedOffset: clampedSelected - pageStart
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
-
function settingsRootValue(item) {
|
|
60
|
-
const group = settingsGroupByLabel(item);
|
|
59
|
+
function settingsRootValue(item, airPlayVisible) {
|
|
60
|
+
const group = settingsGroupByLabel(item, airPlayVisible);
|
|
61
61
|
return group ? `${group.items.length} settings ›` : undefined;
|
|
62
62
|
}
|
|
63
|
-
function settingsGroupByLabel(label) {
|
|
64
|
-
return
|
|
63
|
+
function settingsGroupByLabel(label, airPlayVisible) {
|
|
64
|
+
return settingsGroupsForAirPlay(airPlayVisible).find(group => group.label === label);
|
|
65
65
|
}
|
|
66
|
-
function settingsGroupsDescription(label) {
|
|
67
|
-
return label ? settingsGroupByLabel(label)?.description ?? 'Press Enter to open this category.' : '';
|
|
66
|
+
function settingsGroupsDescription(label, airPlayVisible) {
|
|
67
|
+
return label ? settingsGroupByLabel(label, airPlayVisible)?.description ?? 'Press Enter to open this category.' : '';
|
|
68
68
|
}
|
|
69
|
-
export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion) {
|
|
69
|
+
export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion, platform = process.platform) {
|
|
70
70
|
switch (item) {
|
|
71
71
|
case 'Cycle display color':
|
|
72
72
|
return settings.theme;
|
|
@@ -81,7 +81,7 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
|
|
|
81
81
|
case 'Audio output':
|
|
82
82
|
return audioOutputSettingValue(settings, diagnostics, backends);
|
|
83
83
|
case 'AirPlay receiver':
|
|
84
|
-
return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
|
|
84
|
+
return airPlayReceiverSettingValue(settings, airPlayDevices, backends, platform);
|
|
85
85
|
case 'Mute or unmute':
|
|
86
86
|
if (!playbackBackendCapabilities(diagnostics.backend).supportsMute) {
|
|
87
87
|
return 'requires mpv';
|
|
@@ -123,7 +123,7 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
|
|
|
123
123
|
}
|
|
124
124
|
export function settingLabel(item, updateCheck, currentVersion, airPlaySupported = true) {
|
|
125
125
|
if (item === 'AirPlay receiver' && !airPlaySupported) {
|
|
126
|
-
return 'AirPlay receiver (
|
|
126
|
+
return 'AirPlay receiver (unavailable)';
|
|
127
127
|
}
|
|
128
128
|
if (item === 'Check for updates') {
|
|
129
129
|
return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
|
|
@@ -26,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
26
26
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
27
27
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
28
28
|
const compact = height < 30;
|
|
29
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: a(`Activity — ${graph.year}`) }) }), _jsxs(Box, { flexDirection: "column", "aria-label": `Activity graph: ${stats.activeDays} active days in ${graph.year}`, children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme),
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: a(`Activity — ${graph.year}`) }) }), _jsxs(Box, { flexDirection: "column", "aria-label": `Activity graph: ${stats.activeDays} active days in ${graph.year}`, children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), _jsxs(Text, { children: [_jsx(Text, { color: textMuted, children: "Active days: " }), _jsxs(Text, { color: themeAccent(theme), children: [stats.activeDays, "/", stats.totalTrackedDays] })] })] }), _jsxs(Box, { marginTop: 1, "aria-hidden": true, children: [_jsx(Text, { color: textMuted, children: a('Less · ') }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
|
|
30
30
|
}
|
|
31
31
|
function renderContributionCells(cells, graphColors, cellGap) {
|
|
32
32
|
const runs = [];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { rightToLeftPattern, strongDirectionPattern } from './bidi-ranges.js';
|
|
2
|
+
/** First-strong direction for single-line text sanitized by sanitizeTerminalText. */
|
|
3
|
+
export function textDirection(text) {
|
|
4
|
+
// UAX #9 P2/P3: numbers, combining marks, emoji and punctuation generally
|
|
5
|
+
// don't establish direction. Inspect the whole name, never the moving slice.
|
|
6
|
+
// Pinned bidi classes avoid relying on locale or the host's ICU version.
|
|
7
|
+
const first = strongDirectionPattern.exec(text)?.[0];
|
|
8
|
+
return first && rightToLeftPattern.test(first) ? 'rtl' : 'ltr';
|
|
9
|
+
}
|
package/dist/ui/theme.js
CHANGED
|
@@ -13,7 +13,46 @@ export const mapLand = '#33414f';
|
|
|
13
13
|
export const mapWater = '#1a2430';
|
|
14
14
|
export const mapMarker = '#7dd3fc';
|
|
15
15
|
export const exploreMapLand = '#a0a0c0';
|
|
16
|
+
const additionalThemeAccents = {
|
|
17
|
+
'acid-yellow': '#f4ff45',
|
|
18
|
+
'blaze-orange': '#ff8a1f',
|
|
19
|
+
'hot-magenta': '#ff2bd6',
|
|
20
|
+
ultramarine: '#6670ff',
|
|
21
|
+
vermilion: '#dc322f',
|
|
22
|
+
'dracula-pink': '#ff79c6',
|
|
23
|
+
'dusty-mauve': '#c49ab3',
|
|
24
|
+
parchment: '#ddd0aa',
|
|
25
|
+
'laser-green': '#39ff14',
|
|
26
|
+
heather: '#b48ead',
|
|
27
|
+
'soft-silver': '#bfc8cd',
|
|
28
|
+
tangerine: '#ff5900',
|
|
29
|
+
scarlet: '#ff1010',
|
|
30
|
+
raspberry: '#d02068',
|
|
31
|
+
indigo: '#5540d8',
|
|
32
|
+
periwinkle: '#9aa8ff',
|
|
33
|
+
jade: '#299e8c',
|
|
34
|
+
cobalt: '#0050ff',
|
|
35
|
+
plum: '#b02e94',
|
|
36
|
+
cocoa: '#946645',
|
|
37
|
+
ivory: '#fff4dc',
|
|
38
|
+
sunflower: '#ffd600',
|
|
39
|
+
butter: '#fff0a0',
|
|
40
|
+
olive: '#858e35',
|
|
41
|
+
apricot: '#ffb875',
|
|
42
|
+
blush: '#f0c3cb',
|
|
43
|
+
aubergine: '#793b71',
|
|
44
|
+
pistachio: '#c5d879',
|
|
45
|
+
wineberry: '#7e174b',
|
|
46
|
+
'midnight-teal': '#005d65',
|
|
47
|
+
'neon-violet': '#7200ff',
|
|
48
|
+
pine: '#006b3c',
|
|
49
|
+
chartreuse: '#c8ff00',
|
|
50
|
+
bronze: '#967400'
|
|
51
|
+
};
|
|
16
52
|
export function themeAccent(theme) {
|
|
53
|
+
const additionalAccent = additionalThemeAccents[theme];
|
|
54
|
+
if (additionalAccent)
|
|
55
|
+
return additionalAccent;
|
|
17
56
|
if (theme === 'amber') {
|
|
18
57
|
return '#ffb000';
|
|
19
58
|
}
|
|
@@ -57,6 +96,13 @@ export function themeAccent(theme) {
|
|
|
57
96
|
}
|
|
58
97
|
export function themeContributionColors(theme) {
|
|
59
98
|
const emptyContribution = '#1c1c1c';
|
|
99
|
+
const additionalAccent = additionalThemeAccents[theme];
|
|
100
|
+
if (additionalAccent) {
|
|
101
|
+
const channels = [1, 3, 5].map(offset => Number.parseInt(additionalAccent.slice(offset, offset + 2), 16));
|
|
102
|
+
// Blend from the empty cell so dark accents still have visible low levels.
|
|
103
|
+
const shades = [0.25, 0.5, 0.75].map(intensity => `#${channels.map(channel => Math.round(28 + (channel - 28) * intensity).toString(16).padStart(2, '0')).join('')}`);
|
|
104
|
+
return [emptyContribution, ...shades, additionalAccent];
|
|
105
|
+
}
|
|
60
106
|
if (theme === 'amber') {
|
|
61
107
|
return [emptyContribution, '#5f3700', '#9a6200', '#d68a00', '#ffb000'];
|
|
62
108
|
}
|
package/dist/ui/use-alarm-tui.js
CHANGED
|
@@ -3,7 +3,12 @@ import { applyTextInput, clamp, isEditableInput } from './app-state.js';
|
|
|
3
3
|
import { adjustTime, alarmEditorFields, alarmInputFromDraft, alarmTextFields, cycleWeekdays, defaultAlarmDraft, draftFromAlarm, toggleWeekday, validateAlarmDraft } from './alarm-editor.js';
|
|
4
4
|
import { alarmPickerChoices } from './screens/AlarmsScreen.js';
|
|
5
5
|
export function useAlarmTui(params) {
|
|
6
|
-
const { service, store, library, setLibrary, screen, selected, setSelected, go, setMessage, playingStation, previewStation } = params;
|
|
6
|
+
const { service, store, library, setLibrary, screen, screenRef, selected, selectedRef, setSelected: setSelectionState, go, setMessage, playingStation, previewStation } = params;
|
|
7
|
+
const setSelected = useCallback(next => {
|
|
8
|
+
const value = typeof next === 'function' ? next(selectedRef.current) : next;
|
|
9
|
+
selectedRef.current = value;
|
|
10
|
+
setSelectionState(value);
|
|
11
|
+
}, [selectedRef, setSelectionState]);
|
|
7
12
|
const [draft, setDraft] = useState(null);
|
|
8
13
|
const [editingField, setEditingField] = useState(false);
|
|
9
14
|
const [editorControl, setEditorControl] = useState(null);
|
|
@@ -38,8 +43,6 @@ export function useAlarmTui(params) {
|
|
|
38
43
|
const activeSelectedKeyRef = useRef(undefined);
|
|
39
44
|
const pendingActiveActionsRef = useRef(new Set());
|
|
40
45
|
const acknowledgedActiveKeysRef = useRef(new Set());
|
|
41
|
-
const screenRef = useRef(screen);
|
|
42
|
-
screenRef.current = screen;
|
|
43
46
|
const alarmsRef = useRef(library.alarms);
|
|
44
47
|
alarmsRef.current = library.alarms;
|
|
45
48
|
const visibleFields = useMemo(() => alarmEditorFields.filter(field => {
|
|
@@ -67,14 +70,16 @@ export function useAlarmTui(params) {
|
|
|
67
70
|
for (const key of pendingActiveActionsRef.current)
|
|
68
71
|
if (!liveKeys.has(key))
|
|
69
72
|
pendingActiveActionsRef.current.delete(key);
|
|
70
|
-
activeAlarmsRef.current
|
|
71
|
-
|
|
73
|
+
if (JSON.stringify(activeAlarmsRef.current) !== JSON.stringify(sessions)) {
|
|
74
|
+
activeAlarmsRef.current = sessions;
|
|
75
|
+
setActiveAlarms(sessions);
|
|
76
|
+
}
|
|
72
77
|
if (sessions.length > 0 && activeCountRef.current === 0 && screenRef.current !== 'alarm-ringing') {
|
|
73
78
|
returnScreenRef.current = screenRef.current;
|
|
74
79
|
go('alarm-ringing', { resetSelection: true, clearMessage: false });
|
|
75
80
|
}
|
|
76
81
|
activeCountRef.current = sessions.length;
|
|
77
|
-
}, [go]);
|
|
82
|
+
}, [go, screenRef]);
|
|
78
83
|
const refreshActive = useCallback(async () => {
|
|
79
84
|
if (activePollingRef.current)
|
|
80
85
|
return;
|
|
@@ -186,14 +191,14 @@ export function useAlarmTui(params) {
|
|
|
186
191
|
try {
|
|
187
192
|
// Persist the safe state before touching native jobs. A failed cleanup
|
|
188
193
|
// can then be retried without another write in the error handler.
|
|
189
|
-
const disabled = store.toggleAlarm(alarm.id, false);
|
|
194
|
+
const disabled = await store.toggleAlarm(alarm.id, false);
|
|
190
195
|
const disabledSnapshot = store.snapshot();
|
|
191
196
|
alarmStateRef.current = disabledSnapshot.alarms;
|
|
192
197
|
setLibrary(disabledSnapshot);
|
|
193
198
|
phase = 'native';
|
|
194
199
|
await service.remove(disabled);
|
|
195
200
|
phase = 'definition';
|
|
196
|
-
store.removeAlarm(alarm.id);
|
|
201
|
+
await store.removeAlarm(alarm.id);
|
|
197
202
|
const snapshot = store.snapshot();
|
|
198
203
|
alarmStateRef.current = snapshot.alarms;
|
|
199
204
|
setLibrary(snapshot);
|
|
@@ -214,7 +219,7 @@ export function useAlarmTui(params) {
|
|
|
214
219
|
await refreshRuntime();
|
|
215
220
|
setMessage(message);
|
|
216
221
|
}, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]);
|
|
217
|
-
const saveDraft = useCallback(() => {
|
|
222
|
+
const saveDraft = useCallback(async () => {
|
|
218
223
|
if (!draft || savingRef.current)
|
|
219
224
|
return;
|
|
220
225
|
const error = validateAlarmDraft(draft);
|
|
@@ -227,7 +232,7 @@ export function useAlarmTui(params) {
|
|
|
227
232
|
setSaving(true);
|
|
228
233
|
try {
|
|
229
234
|
const input = alarmInputFromDraft(draft);
|
|
230
|
-
const alarm = draft.id ? store.updateAlarm(draft.id, input) : store.addAlarm(input);
|
|
235
|
+
const alarm = draft.id ? await store.updateAlarm(draft.id, input) : await store.addAlarm(input);
|
|
231
236
|
setLibrary(store.snapshot());
|
|
232
237
|
setValidationError(null);
|
|
233
238
|
go('alarms', { resetSelection: true, clearMessage: false });
|
|
@@ -246,7 +251,7 @@ export function useAlarmTui(params) {
|
|
|
246
251
|
setSaving(false);
|
|
247
252
|
}, [draft, go, setLibrary, setMessage, store, syncPersisted]);
|
|
248
253
|
const editSelected = useCallback(() => {
|
|
249
|
-
const alarm = alarmStateRef.current[
|
|
254
|
+
const alarm = alarmStateRef.current[selectedRef.current - 1];
|
|
250
255
|
if (!alarm)
|
|
251
256
|
return;
|
|
252
257
|
setDraft(draftFromAlarm(alarm));
|
|
@@ -260,7 +265,7 @@ export function useAlarmTui(params) {
|
|
|
260
265
|
setValidationError(null);
|
|
261
266
|
setSelected(0);
|
|
262
267
|
go('alarm-editor', { resetSelection: true });
|
|
263
|
-
}, [go,
|
|
268
|
+
}, [go, selectedRef, setSelected]);
|
|
264
269
|
const runVerification = useCallback(() => {
|
|
265
270
|
if (verificationRunningRef.current)
|
|
266
271
|
return;
|
|
@@ -277,6 +282,9 @@ export function useAlarmTui(params) {
|
|
|
277
282
|
void service.verifySetup(alarm, library.settings, update => setVerification(update)).then(report => { setVerification(report); setMessage(report.state === 'passed' ? 'Alarm setup verified. This machine completed every rehearsal step.' : report.state === 'warning' ? 'Alarm setup works, with limitations shown in the verification report.' : 'Alarm setup verification found blockers. Review the failed steps before relying on it.'); }).catch(error => { setVerification(current => ({ ...current ?? initial, state: 'failed', finishedAt: new Date().toISOString(), steps: [...(current?.steps ?? []), { id: 'internal', label: 'Verification runner', state: 'failed', detail: messageOf(error), critical: true }] })); setMessage(`Alarm setup verification failed: ${messageOf(error)}`); }).finally(() => { verificationRunningRef.current = false; void refreshRuntime(); });
|
|
278
283
|
}, [library.settings, refreshRuntime, service, setMessage, store]);
|
|
279
284
|
const handleInput = useCallback((input, key) => {
|
|
285
|
+
const screen = screenRef.current;
|
|
286
|
+
const selected = selectedRef.current;
|
|
287
|
+
const editorField = visibleFields[clamp(selected, visibleFields.length - 1)] ?? 'label';
|
|
280
288
|
if (!['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'].includes(screen))
|
|
281
289
|
return false;
|
|
282
290
|
if (key.scrollDelta) {
|
|
@@ -355,11 +363,7 @@ export function useAlarmTui(params) {
|
|
|
355
363
|
return true;
|
|
356
364
|
}
|
|
357
365
|
if (input === ' ' && alarm) {
|
|
358
|
-
const updated = store.toggleAlarm(alarm.id);
|
|
359
|
-
const snapshot = store.snapshot();
|
|
360
|
-
alarmStateRef.current = snapshot.alarms;
|
|
361
|
-
setLibrary(snapshot);
|
|
362
|
-
void syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`);
|
|
366
|
+
void (async () => { const updated = await store.toggleAlarm(alarm.id); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); await syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`); })().catch(error => setMessage(`Alarm save failed: ${messageOf(error)}`));
|
|
363
367
|
return true;
|
|
364
368
|
}
|
|
365
369
|
if (input === 'x' && alarm) {
|
|
@@ -408,11 +412,7 @@ export function useAlarmTui(params) {
|
|
|
408
412
|
return true;
|
|
409
413
|
}
|
|
410
414
|
if (input === 'g' && alarm) {
|
|
411
|
-
const updated = store.updateAlarm(alarm.id, { reliability: { ...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm } });
|
|
412
|
-
const snapshot = store.snapshot();
|
|
413
|
-
alarmStateRef.current = snapshot.alarms;
|
|
414
|
-
setLibrary(snapshot);
|
|
415
|
-
void syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`);
|
|
415
|
+
void (async () => { const updated = await store.updateAlarm(alarm.id, { reliability: { ...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm } }); const snapshot = store.snapshot(); alarmStateRef.current = snapshot.alarms; setLibrary(snapshot); await syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`); })().catch(error => setMessage(`Alarm save failed: ${messageOf(error)}`));
|
|
416
416
|
return true;
|
|
417
417
|
}
|
|
418
418
|
if (input === 'b' || key.escape) {
|
|
@@ -692,7 +692,7 @@ export function useAlarmTui(params) {
|
|
|
692
692
|
return true;
|
|
693
693
|
}
|
|
694
694
|
return false;
|
|
695
|
-
}, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl,
|
|
695
|
+
}, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screenRef, selectedRef, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
|
|
696
696
|
const openActive = useCallback(() => {
|
|
697
697
|
if (!activeAlarms.length) {
|
|
698
698
|
setMessage('No alarm is currently playing.');
|
|
@@ -704,7 +704,7 @@ export function useAlarmTui(params) {
|
|
|
704
704
|
activeSelectedKeyRef.current = activeAlarms[0]?.key;
|
|
705
705
|
setActiveSelected(0);
|
|
706
706
|
go('alarm-ringing', { resetSelection: true, clearMessage: false });
|
|
707
|
-
}, [activeAlarms, go, setMessage]);
|
|
707
|
+
}, [activeAlarms, go, screenRef, setMessage]);
|
|
708
708
|
return { draft, editorField, editingField, editorControl, timeSegment, weekdayIndex, validationError, saving, pickerChoices, pickerFallback, runtime, verification, activeAlarms, activeSelected, snoozeMinutes, deletingId, busyAlarmIds, itemCount(current) { if (current === 'alarms')
|
|
709
709
|
return library.alarms.length + 2; if (current === 'alarm-editor')
|
|
710
710
|
return visibleFields.length; if (current === 'alarm-picker')
|
package/dist/ui/use-app-input.js
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import { useCallback, useEffect } from 'react';
|
|
2
|
-
import { useInput } from 'ink';
|
|
2
|
+
import { useInput, usePaste } from 'ink';
|
|
3
3
|
import { homeItems, settingsItemsForPage, settingsPageForRootItem } from './screen-items.js';
|
|
4
4
|
import { applyTextInput, favoriteTarget, isEditableInput, isPlainPrintableInput, isSearchFavoriteShortcut, mediaTransportActionForInput, searchEditingArrowAction, shouldHandleKeyboardEvent, shouldTuneSearchResult, shouldToggleNearbyLocationShortcut } from './app-state.js';
|
|
5
5
|
import { parseTerminalMouseEvents, primaryMousePress, wheelScrollDelta } from './terminal-mouse.js';
|
|
6
|
-
import { completeCommand } from './help-content.js';
|
|
6
|
+
import { completeCommand, commandNamesForAirPlay } from './help-content.js';
|
|
7
7
|
import { commitImmediateSelection } from './selection-state.js';
|
|
8
8
|
import { sanitizeTerminalText } from '../safety.js';
|
|
9
|
-
export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, cancelPendingAutoSkip, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, confirmCtrlCExit, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openSettingsPage, closeSettingsPage, openScreen, handleAlarmInput, openAlarmForStation, openActiveAlarms, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, repairMcpIntegrations, setAgentIntegrationEnabled, runSearch, saveLearnedTransportKey, screen,
|
|
9
|
+
export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, airPlaySupported, cancelPendingAutoSkip, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, confirmCtrlCExit, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openSettingsPage, closeSettingsPage, openScreen, handleAlarmInput, openAlarmForStation, openActiveAlarms, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, repairMcpIntegrations, setAgentIntegrationEnabled, runSearch, saveLearnedTransportKey, screen, screenRef, settingsPageRef, searchQuery, selectedRef, selectedStationForInput, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleAgentSetting, toggleNearbyLocation, toggleDirectoryVoting, toggleRadioGarden, toggleSkipBrokenStreams, updateFromSettings }) {
|
|
10
10
|
const reportInputError = useCallback((error) => {
|
|
11
11
|
setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
|
|
12
12
|
}, [setMessage]);
|
|
13
13
|
const commitSelection = (next) => {
|
|
14
|
-
commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(
|
|
14
|
+
commitImmediateSelection(selectedRef, setSelected, next, currentItemCount(screenRef.current));
|
|
15
15
|
};
|
|
16
16
|
const moveSelection = (delta) => {
|
|
17
|
-
if (hasStationSelection(
|
|
17
|
+
if (hasStationSelection(screenRef.current))
|
|
18
18
|
cancelPendingAutoSkip();
|
|
19
19
|
commitSelection(selectedRef.current + delta);
|
|
20
20
|
};
|
|
21
21
|
useEffect(() => {
|
|
22
22
|
const handleData = (data) => {
|
|
23
|
+
const screen = screenRef.current;
|
|
23
24
|
const rawInput = String(data);
|
|
24
25
|
if (capturingTransportAction) {
|
|
25
26
|
if (rawInput === '\u001B') {
|
|
@@ -94,6 +95,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
|
|
|
94
95
|
moveExploreCursorToCell,
|
|
95
96
|
saveLearnedTransportKey,
|
|
96
97
|
screen,
|
|
98
|
+
screenRef,
|
|
97
99
|
selectedRef,
|
|
98
100
|
setCapturingTransportAction,
|
|
99
101
|
setMessage,
|
|
@@ -103,6 +105,8 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
|
|
|
103
105
|
togglePause
|
|
104
106
|
]);
|
|
105
107
|
const handleInput = async (input, key) => {
|
|
108
|
+
const screen = screenRef.current;
|
|
109
|
+
const settingsPage = settingsPageRef.current;
|
|
106
110
|
if (key.ctrl && input === 'c') {
|
|
107
111
|
confirmCtrlCExit();
|
|
108
112
|
return;
|
|
@@ -113,7 +117,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
|
|
|
113
117
|
if (capturingTransportAction) {
|
|
114
118
|
return;
|
|
115
119
|
}
|
|
116
|
-
|
|
120
|
+
// Suppress only the mouse/media event already handled by the raw listener.
|
|
121
|
+
// Other keys in the same chunk (or immediately afterward) must still work.
|
|
122
|
+
const editingPlainText = isPlainPrintableInput(input) && (commandMode || (screen === 'search' && editingSearch) || ((screen === 'countries' || screen === 'map') && editingCountryFilter));
|
|
123
|
+
if (!editingPlainText && Date.now() - lastRawTransportAtRef.current < 50 && isRawInputEcho(input, key, settingsRef.current)) {
|
|
117
124
|
return;
|
|
118
125
|
}
|
|
119
126
|
if (commandMode) {
|
|
@@ -130,7 +137,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
|
|
|
130
137
|
}
|
|
131
138
|
if (key.tab) {
|
|
132
139
|
// Complete the command name only while still typing it (no args yet).
|
|
133
|
-
setCommandText(value => (/\s/.test(value) ? value : completeCommand(value)));
|
|
140
|
+
setCommandText(value => (/\s/.test(value) ? value : completeCommand(value, commandNamesForAirPlay(airPlaySupported))));
|
|
134
141
|
return;
|
|
135
142
|
}
|
|
136
143
|
if (isEditableInput(input, key)) {
|
|
@@ -440,7 +447,7 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
|
|
|
440
447
|
return;
|
|
441
448
|
}
|
|
442
449
|
if (screen === 'settings') {
|
|
443
|
-
const item = settingsItemsForPage(settingsPage)[selectedRef.current];
|
|
450
|
+
const item = settingsItemsForPage(settingsPage, airPlaySupported)[selectedRef.current];
|
|
444
451
|
if (settingsPage === 'root') {
|
|
445
452
|
const targetPage = settingsPageForRootItem(item);
|
|
446
453
|
if (targetPage) {
|
|
@@ -558,8 +565,35 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, ca
|
|
|
558
565
|
}
|
|
559
566
|
};
|
|
560
567
|
useInput((input, key) => {
|
|
568
|
+
// Ink preserves non-escape text chunks for paste, including repeated Tab
|
|
569
|
+
// bytes. Only a chunk consisting entirely of Tabs represents navigation.
|
|
570
|
+
if (/^\t{2,}$/.test(input)) {
|
|
571
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
572
|
+
void handleInput('', { ...key, tab: true }).catch(reportInputError);
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
561
576
|
void handleInput(input, key).catch(reportInputError);
|
|
562
577
|
});
|
|
578
|
+
// Keep bracketed paste on its own channel so pasted Tabs stay text. The
|
|
579
|
+
// existing text/command handling still receives each complete pasted string.
|
|
580
|
+
usePaste(input => {
|
|
581
|
+
void handleInput(input, pasteInputKey).catch(reportInputError);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
const pasteInputKey = {
|
|
585
|
+
upArrow: false, downArrow: false, leftArrow: false, rightArrow: false,
|
|
586
|
+
pageDown: false, pageUp: false, home: false, end: false,
|
|
587
|
+
return: false, escape: false, ctrl: false, shift: false, tab: false,
|
|
588
|
+
backspace: false, delete: false, meta: false,
|
|
589
|
+
super: false, hyper: false, capsLock: false, numLock: false
|
|
590
|
+
};
|
|
591
|
+
function isRawInputEcho(input, key, settings) {
|
|
592
|
+
return Boolean(mediaTransportActionForInput(input, settings.mediaKeys) ||
|
|
593
|
+
mediaTransportActionForInput(`\u001B${input}`, settings.mediaKeys) ||
|
|
594
|
+
((key.shift || key.ctrl || key.meta) && (key.leftArrow || key.rightArrow)) ||
|
|
595
|
+
/^\[<\d+;\d+;\d+[Mm]$/.test(input) ||
|
|
596
|
+
input.startsWith('[M'));
|
|
563
597
|
}
|
|
564
598
|
function shouldScrollSelectionWithWheel(screen, commandMode, editingCountryFilter) {
|
|
565
599
|
if (commandMode || editingCountryFilter) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
import { favoriteTarget, normalizeMediaKeyBindings, parseMediaActionName } from './app-state.js';
|
|
3
3
|
import { importStreamUrl } from '../streams/import-stream.js';
|
|
4
|
-
export function useCommandExecutor({ beginLearningTransportKey, countries, go, loadCountry, openAirPlaySettings, openLibrary, persistLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
|
|
4
|
+
export function useCommandExecutor({ beginLearningTransportKey, cancelPendingTune, countries, go, loadCountry, openAirPlaySettings, openAirPlayCode, openLibrary, persistLibrary, player, playingStation, providers, resetLearnedTransportKeys, runSearch, screen, screenRef, selectedStation, setCountries, setFilters, setLibrary, setMessage, setSearchQuery, setSleepUntil, setVolume, settingsRef, store, toggleFavorite, toggleMute, updateCommand, updateSettings }) {
|
|
5
5
|
return useCallback(async (rawCommand) => {
|
|
6
6
|
const trimmed = rawCommand.trim();
|
|
7
7
|
if (!trimmed) {
|
|
@@ -20,16 +20,18 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
20
20
|
setMessage('Usage: :country <name or code>');
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
|
+
const origin = screenRef.current;
|
|
23
24
|
const availableCountries = countries.length > 0 ? countries : await providers.countries();
|
|
24
25
|
if (countries.length === 0) {
|
|
25
26
|
setCountries(availableCountries);
|
|
26
27
|
}
|
|
27
28
|
const match = availableCountries.find(country => `${country.code} ${country.name}`.toLowerCase().includes(value.toLowerCase()));
|
|
28
29
|
if (!match) {
|
|
29
|
-
|
|
30
|
+
if (screenRef.current === origin)
|
|
31
|
+
setMessage(`Country not found: ${value}`);
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
32
|
-
await loadCountry(match);
|
|
34
|
+
await loadCountry(match, screenRef.current === origin);
|
|
33
35
|
return;
|
|
34
36
|
}
|
|
35
37
|
if (name === 'codec') {
|
|
@@ -63,20 +65,20 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
63
65
|
}
|
|
64
66
|
if (name === 'location') {
|
|
65
67
|
const enabled = value === 'on' || value === 'true' || value === '1';
|
|
66
|
-
updateSettings({ enableNearbyLocation: enabled });
|
|
68
|
+
await updateSettings({ enableNearbyLocation: enabled });
|
|
67
69
|
setMessage(`Nearby location lookup ${enabled ? 'enabled' : 'disabled'}.`);
|
|
68
70
|
return;
|
|
69
71
|
}
|
|
70
72
|
if (name === 'timeout') {
|
|
71
73
|
const seconds = Number(value);
|
|
72
74
|
if (Number.isFinite(seconds)) {
|
|
73
|
-
updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
|
|
75
|
+
await updateSettings({ tuneTimeoutSeconds: Math.min(45, Math.max(3, seconds)) });
|
|
74
76
|
}
|
|
75
77
|
return;
|
|
76
78
|
}
|
|
77
79
|
if (name === 'skip') {
|
|
78
80
|
const enabled = value !== 'off' && value !== 'false' && value !== '0';
|
|
79
|
-
updateSettings({ skipBrokenStreams: enabled });
|
|
81
|
+
await updateSettings({ skipBrokenStreams: enabled });
|
|
80
82
|
return;
|
|
81
83
|
}
|
|
82
84
|
if (name === 'learn' || name === 'bind' || name === 'key') {
|
|
@@ -99,10 +101,10 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
99
101
|
}
|
|
100
102
|
if (name === 'airplay-code' || name === 'airplay-passcode') {
|
|
101
103
|
if (!value.trim()) {
|
|
102
|
-
|
|
104
|
+
openAirPlayCode();
|
|
103
105
|
return;
|
|
104
106
|
}
|
|
105
|
-
const result = player.submitAirPlayPasscode(value.trim());
|
|
107
|
+
const result = await player.submitAirPlayPasscode(value.trim());
|
|
106
108
|
if (result.message) {
|
|
107
109
|
setMessage(result.message);
|
|
108
110
|
}
|
|
@@ -139,7 +141,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
139
141
|
}
|
|
140
142
|
if (name === 'export' || name === 'backup') {
|
|
141
143
|
try {
|
|
142
|
-
const backupPath = store.exportBackup(value || undefined);
|
|
144
|
+
const backupPath = await store.exportBackup(value || undefined);
|
|
143
145
|
setMessage(`Backup exported: ${backupPath}`);
|
|
144
146
|
}
|
|
145
147
|
catch (error) {
|
|
@@ -156,7 +158,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
156
158
|
const [url = '', ...nameParts] = rest;
|
|
157
159
|
setMessage('Reading stream metadata...');
|
|
158
160
|
const result = await importStreamUrl(url, nameParts.join(' ') || undefined);
|
|
159
|
-
const nextLibrary = persistLibrary(() => store.addImported([result.station]));
|
|
161
|
+
const nextLibrary = await persistLibrary(() => store.addImported([result.station]));
|
|
160
162
|
if (!nextLibrary)
|
|
161
163
|
return;
|
|
162
164
|
openLibrary();
|
|
@@ -175,7 +177,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
175
177
|
return;
|
|
176
178
|
}
|
|
177
179
|
try {
|
|
178
|
-
const result = store.importBackup(value);
|
|
180
|
+
const result = await store.importBackup(value);
|
|
179
181
|
settingsRef.current = result.state.settings;
|
|
180
182
|
setLibrary(result.state);
|
|
181
183
|
setMessage(result.safetyBackupPath
|
|
@@ -192,6 +194,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
192
194
|
return;
|
|
193
195
|
}
|
|
194
196
|
if (name === 'stop') {
|
|
197
|
+
cancelPendingTune();
|
|
195
198
|
persistLibrary(() => store.finishActiveListeningSession());
|
|
196
199
|
await player.stop();
|
|
197
200
|
return;
|
|
@@ -213,18 +216,21 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
213
216
|
: name === 'motion'
|
|
214
217
|
? 'reduceMotion'
|
|
215
218
|
: 'transparentBackground';
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
+
const saved = await updateSettings(value === ''
|
|
220
|
+
? current => ({ [key]: !current[key] })
|
|
221
|
+
: { [key]: value === 'on' || value === 'true' || value === '1' });
|
|
222
|
+
setMessage(`${name} ${saved.settings[key] ? 'on' : 'off'}.`);
|
|
219
223
|
return;
|
|
220
224
|
}
|
|
221
225
|
setMessage(`Unknown command: ${name}`);
|
|
222
226
|
}, [
|
|
223
227
|
beginLearningTransportKey,
|
|
228
|
+
cancelPendingTune,
|
|
224
229
|
countries,
|
|
225
230
|
go,
|
|
226
231
|
loadCountry,
|
|
227
232
|
openAirPlaySettings,
|
|
233
|
+
openAirPlayCode,
|
|
228
234
|
openLibrary,
|
|
229
235
|
persistLibrary,
|
|
230
236
|
player,
|
|
@@ -233,6 +239,7 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
|
|
|
233
239
|
resetLearnedTransportKeys,
|
|
234
240
|
runSearch,
|
|
235
241
|
screen,
|
|
242
|
+
screenRef,
|
|
236
243
|
selectedStation,
|
|
237
244
|
setCountries,
|
|
238
245
|
setFilters,
|