@ciphore/radiocli 0.2.3 → 0.2.5

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.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -15,7 +15,7 @@ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
15
  import { selectedAirPlayDevice } from './airplay-settings.js';
16
16
  import { AdaptiveContent } from './AdaptiveContent.js';
17
17
  import { AlarmsScreen, AlarmEditorScreen, AlarmPickerScreen, AlarmRingingScreen } from './screens/AlarmsScreen.js';
18
- export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui }) {
18
+ export function AppContent({ platform = process.platform, airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui, airPlaySupported = true }) {
19
19
  if (layout.compact) {
20
20
  if (screen === 'alarms')
21
21
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -25,7 +25,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
25
25
  return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
26
26
  if (screen === 'alarm-ringing')
27
27
  return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
28
- return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel }));
28
+ return (_jsx(AdaptiveContent, { platform: platform, mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel, airPlaySupported: airPlaySupported }));
29
29
  }
30
30
  if (screen === 'alarms')
31
31
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -60,16 +60,16 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
60
60
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
61
61
  }
62
62
  if (screen === 'settings') {
63
- return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
63
+ return (_jsx(SettingsScreen, { platform: platform, page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows, airPlaySupported: airPlaySupported }));
64
64
  }
65
65
  if (screen === 'airplay-settings') {
66
- return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
66
+ return (_jsx(AirPlaySettingsScreen, { platform: platform, selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
67
67
  }
68
68
  if (screen === 'airplay-code') {
69
69
  return (_jsx(AirPlayCodeScreen, { code: airPlayCode, playback: playback, selectedDevice: selectedAirPlayDevice(library.settings, airPlayDevices), theme: theme, width: frameWidth }));
70
70
  }
71
71
  if (screen === 'help') {
72
- return _jsx(HelpScreen, { theme: theme, width: frameWidth, height: layout.contentRows, selected: selected });
72
+ return _jsx(HelpScreen, { airPlaySupported: airPlaySupported, theme: theme, width: frameWidth, height: layout.contentRows, selected: selected });
73
73
  }
74
74
  return _jsx(Text, { children: "Unknown screen." });
75
75
  }
@@ -1,15 +1,18 @@
1
+ import { airPlayLocalDeviceLabel } from '../player/backend-install.js';
1
2
  export function isAirPlayBackendAvailable(backends) {
2
3
  return backends.includes('airplay');
3
4
  }
4
5
  export function selectedAirPlayDevice(settings, devices) {
5
6
  return devices.find(device => device.id === settings.preferredAirPlayDevice);
6
7
  }
7
- export function airPlayReceiverSettingValue(settings, devices, backends) {
8
+ export function airPlayReceiverSettingValue(settings, devices, backends, platform = process.platform) {
9
+ if (platform === 'linux' && !isAirPlayBackendAvailable(backends))
10
+ return 'Setup required';
8
11
  const device = selectedAirPlayDevice(settings, devices);
9
12
  const count = airPlayDeviceCountLabel(devices.length);
10
13
  const playbackStatus = isAirPlayBackendAvailable(backends) ? undefined : 'playback unavailable';
11
14
  if (device) {
12
- return [device.name, device.local ? 'this Mac' : playbackStatus ?? count].join(' · ');
15
+ return [device.name, device.local ? airPlayLocalDeviceLabel(platform) : playbackStatus ?? count].join(' · ');
13
16
  }
14
17
  if (settings.preferredAirPlayDevice) {
15
18
  return ['Saved receiver missing', playbackStatus ?? count].join(' · ');
@@ -19,8 +22,10 @@ export function airPlayReceiverSettingValue(settings, devices, backends) {
19
22
  }
20
23
  return playbackStatus ? `No receivers found · ${playbackStatus}` : 'No receivers found';
21
24
  }
22
- export function airPlayAvailability(backends, devices) {
25
+ export function airPlayAvailability(backends, devices, platform = process.platform) {
23
26
  if (!isAirPlayBackendAvailable(backends)) {
27
+ if (platform === 'linux')
28
+ return { ready: false, label: 'Setup required', detail: 'Run radiocli setup --airplay, then radiocli doctor to check FFmpeg, Avahi, and the optional sender.' };
24
29
  const detail = devices.length > 0
25
30
  ? 'RadioCLI can see receivers, but this install cannot start AirPlay yet. Run radiocli doctor.'
26
31
  : 'AirPlay is not ready on this install. Run radiocli doctor.';
@@ -31,6 +36,8 @@ export function airPlayAvailability(backends, devices) {
31
36
  };
32
37
  }
33
38
  if (devices.length === 0) {
39
+ if (platform === 'linux')
40
+ return { ready: false, label: 'Unverified', detail: 'Refresh to find IPv4 receivers and verify experimental AirPlay. Select a receiver each session.' };
34
41
  return {
35
42
  ready: true,
36
43
  label: 'Ready',
@@ -43,9 +50,9 @@ export function airPlayAvailability(backends, devices) {
43
50
  detail: `${airPlayDeviceCountLabel(devices.length)} visible on this network.`
44
51
  };
45
52
  }
46
- export function airPlayDeviceDetail(device) {
53
+ export function airPlayDeviceDetail(device, platform = process.platform) {
47
54
  if (device.local) {
48
- return `${device.host}:${device.port} · this Mac · use local output`;
55
+ return `${device.host}:${device.port} · ${airPlayLocalDeviceLabel(platform)} · use local output`;
49
56
  }
50
57
  const security = device.requiresPassword ? 'code shown on receiver' : 'no code';
51
58
  const protocol = device.airplay2 ? 'AirPlay 2' : 'AirPlay';
@@ -38,7 +38,7 @@ export const initialStationContexts = {
38
38
  },
39
39
  library: {
40
40
  title: 'Library',
41
- subtitle: 'Favorites, recent stations, and imported streams',
41
+ subtitle: 'Favorites, imported streams, and recent stations',
42
42
  stations: []
43
43
  }
44
44
  };
@@ -58,6 +58,13 @@ export function searchEditingArrowAction(key, hasResults) {
58
58
  }
59
59
  return null;
60
60
  }
61
+ export function shouldTuneSearchResult(query, lastSubmittedQuery) {
62
+ const trimmedQuery = query.trim();
63
+ return !trimmedQuery || trimmedQuery === lastSubmittedQuery.trim();
64
+ }
65
+ export function isSearchFavoriteShortcut(input, key) {
66
+ return input.toLowerCase() === 'f' && Boolean(key.ctrl || key.meta || key.super);
67
+ }
61
68
  export function clampVolume(value) {
62
69
  return Math.min(100, Math.max(0, Math.round(value)));
63
70
  }
package/dist/ui/ascii.js CHANGED
@@ -64,6 +64,14 @@ function asciiGlyph(code) {
64
64
  if (code === 0x25c9 || code === 0x25d2) {
65
65
  return 'o'; // ◉ ◒
66
66
  }
67
+ if (code === 0x2218)
68
+ return 'o'; // ring operator used by receiver patterns
69
+ if (code === 0x25c6 || code === 0x25c7)
70
+ return '*'; // decorative diamonds
71
+ if (code === 0x201c || code === 0x201d)
72
+ return '"';
73
+ if (code === 0x2018 || code === 0x2019)
74
+ return "'";
67
75
  if (code === 0x25b2) {
68
76
  return '^'; // ▲
69
77
  }
@@ -105,8 +113,9 @@ function asciiGlyph(code) {
105
113
  return '^';
106
114
  if (code === 0x2193)
107
115
  return 'v';
108
- // Drop zero-width and combining marks entirely so they do not skew widths.
109
- if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) {
116
+ // Drop invisible separators, but retain combining accents in station data.
117
+ // Width measurement already accounts for combining marks as zero columns.
118
+ if (code === 0x200b || code === 0xfeff) {
110
119
  return '';
111
120
  }
112
121
  // Unknown Unicode is user/content data more often than decoration. Preserve
@@ -0,0 +1,45 @@
1
+ // Generated by scripts/generate-bidi-ranges.mjs; do not edit.
2
+ // Unicode 17.0.0: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt
3
+ /*
4
+ UNICODE LICENSE V3
5
+
6
+ COPYRIGHT AND PERMISSION NOTICE
7
+
8
+ Copyright © 1991-2026 Unicode, Inc.
9
+
10
+ NOTICE TO USER: Carefully read the following legal agreement. BY
11
+ DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
12
+ SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
13
+ TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
14
+ DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a
17
+ copy of data files and any associated documentation (the "Data Files") or
18
+ software and any associated documentation (the "Software") to deal in the
19
+ Data Files or Software without restriction, including without limitation
20
+ the rights to use, copy, modify, merge, publish, distribute, and/or sell
21
+ copies of the Data Files or Software, and to permit persons to whom the
22
+ Data Files or Software are furnished to do so, provided that either (a)
23
+ this copyright and permission notice appear with all copies of the Data
24
+ Files or Software, or (b) this copyright and permission notice appear in
25
+ associated Documentation.
26
+
27
+ THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
28
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
29
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
30
+ THIRD PARTY RIGHTS.
31
+
32
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
33
+ BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
34
+ OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
35
+ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
36
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
37
+ FILES OR SOFTWARE.
38
+
39
+ Except as contained in this notice, the name of a copyright holder shall
40
+ not be used in advertising or otherwise to promote the sale, use or other
41
+ dealings in these Data Files or Software without prior written
42
+ authorization of the copyright holder.
43
+ */
44
+ export const strongDirectionPattern = /[\u{41}-\u{5a}\u{61}-\u{7a}\u{aa}\u{b5}\u{ba}\u{c0}-\u{d6}\u{d8}-\u{f6}\u{f8}-\u{2b8}\u{2bb}-\u{2c1}\u{2d0}-\u{2d1}\u{2e0}-\u{2e4}\u{2ee}\u{370}-\u{373}\u{376}-\u{37d}\u{37f}-\u{383}\u{386}\u{388}-\u{3f5}\u{3f7}-\u{482}\u{48a}-\u{589}\u{58b}-\u{58c}\u{590}\u{5be}\u{5c0}\u{5c3}\u{5c6}\u{5c8}-\u{5ff}\u{608}\u{60b}\u{60d}\u{61b}-\u{64a}\u{66d}-\u{66f}\u{671}-\u{6d5}\u{6e5}-\u{6e6}\u{6ee}-\u{6ef}\u{6fa}-\u{710}\u{712}-\u{72f}\u{74b}-\u{7a5}\u{7b1}-\u{7ea}\u{7f4}-\u{7f5}\u{7fa}-\u{7fc}\u{7fe}-\u{815}\u{81a}\u{824}\u{828}\u{82e}-\u{858}\u{85c}-\u{88f}\u{892}-\u{896}\u{8a0}-\u{8c9}\u{903}-\u{939}\u{93b}\u{93d}-\u{940}\u{949}-\u{94c}\u{94e}-\u{950}\u{958}-\u{961}\u{964}-\u{980}\u{982}-\u{9bb}\u{9bd}-\u{9c0}\u{9c5}-\u{9cc}\u{9ce}-\u{9e1}\u{9e4}-\u{9f1}\u{9f4}-\u{9fa}\u{9fc}-\u{9fd}\u{9ff}-\u{a00}\u{a03}-\u{a3b}\u{a3d}-\u{a40}\u{a43}-\u{a46}\u{a49}-\u{a4a}\u{a4e}-\u{a50}\u{a52}-\u{a6f}\u{a72}-\u{a74}\u{a76}-\u{a80}\u{a83}-\u{abb}\u{abd}-\u{ac0}\u{ac6}\u{ac9}-\u{acc}\u{ace}-\u{ae1}\u{ae4}-\u{af0}\u{af2}-\u{af9}\u{b00}\u{b02}-\u{b3b}\u{b3d}-\u{b3e}\u{b40}\u{b45}-\u{b4c}\u{b4e}-\u{b54}\u{b57}-\u{b61}\u{b64}-\u{b81}\u{b83}-\u{bbf}\u{bc1}-\u{bcc}\u{bce}-\u{bf2}\u{bfb}-\u{bff}\u{c01}-\u{c03}\u{c05}-\u{c3b}\u{c3d}\u{c41}-\u{c45}\u{c49}\u{c4e}-\u{c54}\u{c57}-\u{c61}\u{c64}-\u{c77}\u{c7f}-\u{c80}\u{c82}-\u{cbb}\u{cbd}-\u{ccb}\u{cce}-\u{ce1}\u{ce4}-\u{cff}\u{d02}-\u{d3a}\u{d3d}-\u{d40}\u{d45}-\u{d4c}\u{d4e}-\u{d61}\u{d64}-\u{d80}\u{d82}-\u{dc9}\u{dcb}-\u{dd1}\u{dd5}\u{dd7}-\u{e30}\u{e32}-\u{e33}\u{e3b}-\u{e3e}\u{e40}-\u{e46}\u{e4f}-\u{eb0}\u{eb2}-\u{eb3}\u{ebd}-\u{ec7}\u{ecf}-\u{f17}\u{f1a}-\u{f34}\u{f36}\u{f38}\u{f3e}-\u{f70}\u{f7f}\u{f85}\u{f88}-\u{f8c}\u{f98}\u{fbd}-\u{fc5}\u{fc7}-\u{102c}\u{1031}\u{1038}\u{103b}-\u{103c}\u{103f}-\u{1057}\u{105a}-\u{105d}\u{1061}-\u{1070}\u{1075}-\u{1081}\u{1083}-\u{1084}\u{1087}-\u{108c}\u{108e}-\u{109c}\u{109e}-\u{135c}\u{1360}-\u{138f}\u{139a}-\u{13ff}\u{1401}-\u{167f}\u{1681}-\u{169a}\u{169d}-\u{1711}\u{1715}-\u{1731}\u{1734}-\u{1751}\u{1754}-\u{1771}\u{1774}-\u{17b3}\u{17b6}\u{17be}-\u{17c5}\u{17c7}-\u{17c8}\u{17d4}-\u{17da}\u{17dc}\u{17de}-\u{17ef}\u{17fa}-\u{17ff}\u{1810}-\u{1884}\u{1887}-\u{18a8}\u{18aa}-\u{191f}\u{1923}-\u{1926}\u{1929}-\u{1931}\u{1933}-\u{1938}\u{193c}-\u{193f}\u{1941}-\u{1943}\u{1946}-\u{19dd}\u{1a00}-\u{1a16}\u{1a19}-\u{1a1a}\u{1a1c}-\u{1a55}\u{1a57}\u{1a5f}\u{1a61}\u{1a63}-\u{1a64}\u{1a6d}-\u{1a72}\u{1a7d}-\u{1a7e}\u{1a80}-\u{1aaf}\u{1ade}-\u{1adf}\u{1aec}-\u{1aff}\u{1b04}-\u{1b33}\u{1b35}\u{1b3b}\u{1b3d}-\u{1b41}\u{1b43}-\u{1b6a}\u{1b74}-\u{1b7f}\u{1b82}-\u{1ba1}\u{1ba6}-\u{1ba7}\u{1baa}\u{1bae}-\u{1be5}\u{1be7}\u{1bea}-\u{1bec}\u{1bee}\u{1bf2}-\u{1c2b}\u{1c34}-\u{1c35}\u{1c38}-\u{1ccf}\u{1cd3}\u{1ce1}\u{1ce9}-\u{1cec}\u{1cee}-\u{1cf3}\u{1cf5}-\u{1cf7}\u{1cfa}-\u{1dbf}\u{1e00}-\u{1fbc}\u{1fbe}\u{1fc2}-\u{1fcc}\u{1fd0}-\u{1fdc}\u{1fe0}-\u{1fec}\u{1ff0}-\u{1ffc}\u{1fff}\u{200e}-\u{200f}\u{2071}-\u{2073}\u{207f}\u{208f}-\u{209f}\u{20f1}-\u{20ff}\u{2102}\u{2107}\u{210a}-\u{2113}\u{2115}\u{2119}-\u{211d}\u{2124}\u{2126}\u{2128}\u{212a}-\u{212d}\u{212f}-\u{2139}\u{213c}-\u{213f}\u{2145}-\u{2149}\u{214e}-\u{214f}\u{2160}-\u{2188}\u{218c}-\u{218f}\u{2336}-\u{237a}\u{2395}\u{242a}-\u{243f}\u{244b}-\u{245f}\u{249c}-\u{24e9}\u{26ac}\u{2800}-\u{28ff}\u{2b74}-\u{2b75}\u{2c00}-\u{2ce4}\u{2ceb}-\u{2cee}\u{2cf2}-\u{2cf8}\u{2d00}-\u{2d7e}\u{2d80}-\u{2ddf}\u{2e5e}-\u{2e7f}\u{2e9a}\u{2ef4}-\u{2eff}\u{2fd6}-\u{2fef}\u{3005}-\u{3007}\u{3021}-\u{3029}\u{302e}-\u{302f}\u{3031}-\u{3035}\u{3038}-\u{303c}\u{3040}-\u{3098}\u{309d}-\u{309f}\u{30a1}-\u{30fa}\u{30fc}-\u{31bf}\u{31e6}-\u{31ee}\u{31f0}-\u{321c}\u{321f}-\u{324f}\u{3260}-\u{327b}\u{327f}-\u{32b0}\u{32c0}-\u{32cb}\u{32d0}-\u{3376}\u{337b}-\u{33dd}\u{33e0}-\u{33fe}\u{3400}-\u{4dbf}\u{4e00}-\u{a48f}\u{a4c7}-\u{a60c}\u{a610}-\u{a66e}\u{a680}-\u{a69d}\u{a6a0}-\u{a6ef}\u{a6f2}-\u{a6ff}\u{a722}-\u{a787}\u{a789}-\u{a801}\u{a803}-\u{a805}\u{a807}-\u{a80a}\u{a80c}-\u{a824}\u{a827}\u{a82d}-\u{a837}\u{a83a}-\u{a873}\u{a878}-\u{a8c3}\u{a8c6}-\u{a8df}\u{a8f2}-\u{a8fe}\u{a900}-\u{a925}\u{a92e}-\u{a946}\u{a952}-\u{a97f}\u{a983}-\u{a9b2}\u{a9b4}-\u{a9b5}\u{a9ba}-\u{a9bb}\u{a9be}-\u{a9e4}\u{a9e6}-\u{aa28}\u{aa2f}-\u{aa30}\u{aa33}-\u{aa34}\u{aa37}-\u{aa42}\u{aa44}-\u{aa4b}\u{aa4d}-\u{aa7b}\u{aa7d}-\u{aaaf}\u{aab1}\u{aab5}-\u{aab6}\u{aab9}-\u{aabd}\u{aac0}\u{aac2}-\u{aaeb}\u{aaee}-\u{aaf5}\u{aaf7}-\u{ab69}\u{ab6c}-\u{abe4}\u{abe6}-\u{abe7}\u{abe9}-\u{abec}\u{abee}-\u{fb1d}\u{fb1f}-\u{fb28}\u{fb2a}-\u{fbc2}\u{fbd3}-\u{fd3d}\u{fd50}-\u{fd8f}\u{fd92}-\u{fdc7}\u{fdf0}-\u{fdfc}\u{fe1a}-\u{fe1f}\u{fe53}\u{fe67}\u{fe6c}-\u{fefe}\u{ff00}\u{ff21}-\u{ff3a}\u{ff41}-\u{ff5a}\u{ff66}-\u{ffdf}\u{ffe7}\u{ffef}\u{10000}-\u{10100}\u{10102}-\u{1013f}\u{1018d}-\u{1018f}\u{1019d}-\u{1019f}\u{101a1}-\u{101fc}\u{101fe}-\u{102df}\u{102fc}-\u{10375}\u{1037b}-\u{1091e}\u{10920}-\u{10a00}\u{10a04}\u{10a07}-\u{10a0b}\u{10a10}-\u{10a37}\u{10a3b}-\u{10a3e}\u{10a40}-\u{10ae4}\u{10ae7}-\u{10b38}\u{10b40}-\u{10d23}\u{10d28}-\u{10d2f}\u{10d3a}-\u{10d3f}\u{10d4a}-\u{10d68}\u{10d6f}-\u{10e5f}\u{10e7f}-\u{10eaa}\u{10ead}-\u{10ecf}\u{10ed9}-\u{10ef9}\u{10f00}-\u{10f45}\u{10f51}-\u{10f81}\u{10f86}-\u{11000}\u{11002}-\u{11037}\u{11047}-\u{11051}\u{11066}-\u{1106f}\u{11071}-\u{11072}\u{11075}-\u{1107e}\u{11082}-\u{110b2}\u{110b7}-\u{110b8}\u{110bb}-\u{110c1}\u{110c3}-\u{110ff}\u{11103}-\u{11126}\u{1112c}\u{11135}-\u{11172}\u{11174}-\u{1117f}\u{11182}-\u{111b5}\u{111bf}-\u{111c8}\u{111cd}-\u{111ce}\u{111d0}-\u{1122e}\u{11232}-\u{11233}\u{11235}\u{11238}-\u{1123d}\u{1123f}-\u{11240}\u{11242}-\u{112de}\u{112e0}-\u{112e2}\u{112eb}-\u{112ff}\u{11302}-\u{1133a}\u{1133d}-\u{1133f}\u{11341}-\u{11365}\u{1136d}-\u{1136f}\u{11375}-\u{113ba}\u{113c1}-\u{113cd}\u{113cf}\u{113d1}\u{113d3}-\u{113e0}\u{113e3}-\u{11437}\u{11440}-\u{11441}\u{11445}\u{11447}-\u{1145d}\u{1145f}-\u{114b2}\u{114b9}\u{114bb}-\u{114be}\u{114c1}\u{114c4}-\u{115b1}\u{115b6}-\u{115bb}\u{115be}\u{115c1}-\u{115db}\u{115de}-\u{11632}\u{1163b}-\u{1163c}\u{1163e}\u{11641}-\u{1165f}\u{1166d}-\u{116aa}\u{116ac}\u{116ae}-\u{116af}\u{116b6}\u{116b8}-\u{1171c}\u{1171e}\u{11720}-\u{11721}\u{11726}\u{1172c}-\u{1182e}\u{11838}\u{1183b}-\u{1193a}\u{1193d}\u{1193f}-\u{11942}\u{11944}-\u{119d3}\u{119d8}-\u{119d9}\u{119dc}-\u{119df}\u{119e1}-\u{11a00}\u{11a07}-\u{11a08}\u{11a0b}-\u{11a32}\u{11a39}-\u{11a3a}\u{11a3f}-\u{11a46}\u{11a48}-\u{11a50}\u{11a57}-\u{11a58}\u{11a5c}-\u{11a89}\u{11a97}\u{11a9a}-\u{11b5f}\u{11b61}\u{11b65}\u{11b67}-\u{11c2f}\u{11c37}\u{11c3e}-\u{11c91}\u{11ca8}-\u{11ca9}\u{11cb1}\u{11cb4}\u{11cb7}-\u{11d30}\u{11d37}-\u{11d39}\u{11d3b}\u{11d3e}\u{11d46}\u{11d48}-\u{11d8f}\u{11d92}-\u{11d94}\u{11d96}\u{11d98}-\u{11ef2}\u{11ef5}-\u{11eff}\u{11f02}-\u{11f35}\u{11f3b}-\u{11f3f}\u{11f41}\u{11f43}-\u{11f59}\u{11f5b}-\u{11fd4}\u{11ff2}-\u{1343f}\u{13441}-\u{13446}\u{13456}-\u{1611d}\u{1612a}-\u{1612c}\u{16130}-\u{16aef}\u{16af5}-\u{16b2f}\u{16b37}-\u{16f4e}\u{16f50}-\u{16f8e}\u{16f93}-\u{16fe1}\u{16fe3}\u{16fe5}-\u{1bc9c}\u{1bc9f}\u{1bca4}-\u{1cbff}\u{1ccd6}-\u{1ccef}\u{1ccfd}-\u{1ccff}\u{1ceb4}-\u{1ceb9}\u{1ced1}-\u{1cedf}\u{1cef1}-\u{1ceff}\u{1cf2e}-\u{1cf2f}\u{1cf47}-\u{1d166}\u{1d16a}-\u{1d172}\u{1d183}-\u{1d184}\u{1d18c}-\u{1d1a9}\u{1d1ae}-\u{1d1e8}\u{1d1eb}-\u{1d1ff}\u{1d246}-\u{1d2ff}\u{1d357}-\u{1d6c0}\u{1d6c2}-\u{1d6da}\u{1d6dc}-\u{1d6fa}\u{1d6fc}-\u{1d714}\u{1d716}-\u{1d734}\u{1d736}-\u{1d74e}\u{1d750}-\u{1d76e}\u{1d770}-\u{1d788}\u{1d78a}-\u{1d7a8}\u{1d7aa}-\u{1d7c2}\u{1d7c4}-\u{1d7cd}\u{1d800}-\u{1d9ff}\u{1da37}-\u{1da3a}\u{1da6d}-\u{1da74}\u{1da76}-\u{1da83}\u{1da85}-\u{1da9a}\u{1daa0}\u{1dab0}-\u{1dfff}\u{1e007}\u{1e019}-\u{1e01a}\u{1e022}\u{1e025}\u{1e02b}-\u{1e08e}\u{1e090}-\u{1e12f}\u{1e137}-\u{1e2ad}\u{1e2af}-\u{1e2eb}\u{1e2f0}-\u{1e2fe}\u{1e300}-\u{1e4eb}\u{1e4f0}-\u{1e5ed}\u{1e5f0}-\u{1e6e2}\u{1e6e4}-\u{1e6e5}\u{1e6e7}-\u{1e6ed}\u{1e6f0}-\u{1e6f4}\u{1e6f6}-\u{1e8cf}\u{1e8d7}-\u{1e943}\u{1e94b}-\u{1eeef}\u{1eef2}-\u{1efff}\u{1f02c}-\u{1f02f}\u{1f094}-\u{1f09f}\u{1f0af}-\u{1f0b0}\u{1f0c0}\u{1f0d0}\u{1f0f6}-\u{1f0ff}\u{1f110}-\u{1f12e}\u{1f130}-\u{1f169}\u{1f170}-\u{1f1ac}\u{1f1ae}-\u{1f25f}\u{1f266}-\u{1f2ff}\u{1f6d9}-\u{1f6db}\u{1f6ed}-\u{1f6ef}\u{1f6fd}-\u{1f6ff}\u{1f7da}-\u{1f7df}\u{1f7ec}-\u{1f7ef}\u{1f7f1}-\u{1f7ff}\u{1f80c}-\u{1f80f}\u{1f848}-\u{1f84f}\u{1f85a}-\u{1f85f}\u{1f888}-\u{1f88f}\u{1f8ae}-\u{1f8af}\u{1f8bc}-\u{1f8bf}\u{1f8c2}-\u{1f8cf}\u{1f8d9}-\u{1f8ff}\u{1fa58}-\u{1fa5f}\u{1fa6e}-\u{1fa6f}\u{1fa7d}-\u{1fa7f}\u{1fa8b}-\u{1fa8d}\u{1fac7}\u{1fac9}-\u{1facc}\u{1fadd}-\u{1fade}\u{1faeb}-\u{1faee}\u{1faf9}-\u{1faff}\u{1fb93}\u{1fbfb}-\u{1fffd}\u{20000}-\u{2fffd}\u{30000}-\u{3fffd}\u{40000}-\u{4fffd}\u{50000}-\u{5fffd}\u{60000}-\u{6fffd}\u{70000}-\u{7fffd}\u{80000}-\u{8fffd}\u{90000}-\u{9fffd}\u{a0000}-\u{afffd}\u{b0000}-\u{bfffd}\u{c0000}-\u{cfffd}\u{d0000}-\u{dfffd}\u{e1000}-\u{efffd}\u{f0000}-\u{ffffd}\u{100000}-\u{10fffd}]/u;
45
+ export const rightToLeftPattern = /[\u{590}\u{5be}\u{5c0}\u{5c3}\u{5c6}\u{5c8}-\u{5ff}\u{608}\u{60b}\u{60d}\u{61b}-\u{64a}\u{66d}-\u{66f}\u{671}-\u{6d5}\u{6e5}-\u{6e6}\u{6ee}-\u{6ef}\u{6fa}-\u{710}\u{712}-\u{72f}\u{74b}-\u{7a5}\u{7b1}-\u{7ea}\u{7f4}-\u{7f5}\u{7fa}-\u{7fc}\u{7fe}-\u{815}\u{81a}\u{824}\u{828}\u{82e}-\u{858}\u{85c}-\u{88f}\u{892}-\u{896}\u{8a0}-\u{8c9}\u{200f}\u{fb1d}\u{fb1f}-\u{fb28}\u{fb2a}-\u{fbc2}\u{fbd3}-\u{fd3d}\u{fd50}-\u{fd8f}\u{fd92}-\u{fdc7}\u{fdf0}-\u{fdfc}\u{fe70}-\u{fefe}\u{10800}-\u{1091e}\u{10920}-\u{10a00}\u{10a04}\u{10a07}-\u{10a0b}\u{10a10}-\u{10a37}\u{10a3b}-\u{10a3e}\u{10a40}-\u{10ae4}\u{10ae7}-\u{10b38}\u{10b40}-\u{10d23}\u{10d28}-\u{10d2f}\u{10d3a}-\u{10d3f}\u{10d4a}-\u{10d68}\u{10d6f}-\u{10e5f}\u{10e7f}-\u{10eaa}\u{10ead}-\u{10ecf}\u{10ed9}-\u{10ef9}\u{10f00}-\u{10f45}\u{10f51}-\u{10f81}\u{10f86}-\u{10fff}\u{1e800}-\u{1e8cf}\u{1e8d7}-\u{1e943}\u{1e94b}-\u{1eeef}\u{1eef2}-\u{1efff}]/u;
@@ -1,17 +1,24 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
3
  import { Text } from 'ink';
4
- import { displayWidth, sliceDisplay, truncate } from '../format.js';
4
+ import { displayWidth, sliceDisplay, sliceDisplayFromEnd, truncate } from '../format.js';
5
+ import { useDisplay } from '../display-context.js';
6
+ import { toAsciiSafe } from '../ascii.js';
7
+ import { textDirection } from '../text-direction.js';
5
8
  const edgePauseMs = 1000;
6
9
  const stepMs = 180;
7
10
  export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
8
- const safeWidth = Math.max(0, width);
11
+ const { ascii, reduceMotion: terminalReduceMotion } = useDisplay();
12
+ const safeWidth = Math.max(0, Math.floor(width));
9
13
  const maxOffset = Math.max(0, displayWidth(text) - safeWidth);
10
- const animated = active && !reduceMotion && maxOffset > 0;
14
+ const animated = active && !reduceMotion && !terminalReduceMotion && safeWidth > 0 && maxOffset > 0;
15
+ // Reset position and cancel the old timer before rendering a changed name or
16
+ // viewport. An effect-based reset can emit a frame using the previous offset.
17
+ return _jsx(MarqueeText, { text: text, width: safeWidth, maxOffset: maxOffset, animated: animated, ascii: ascii }, JSON.stringify([text, safeWidth, animated]));
18
+ }
19
+ function MarqueeText({ text, width, maxOffset, animated, ascii }) {
20
+ const direction = useMemo(() => textDirection(text), [text]);
11
21
  const [offset, setOffset] = useState(0);
12
- useEffect(() => {
13
- setOffset(0);
14
- }, [active, reduceMotion, safeWidth, text]);
15
22
  useEffect(() => {
16
23
  if (!animated)
17
24
  return;
@@ -21,8 +28,11 @@ export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
21
28
  }, delay);
22
29
  return () => clearTimeout(timer);
23
30
  }, [animated, maxOffset, offset]);
31
+ const progress = Math.min(offset, maxOffset);
32
+ // Move the window toward the beginning for RTL text, keeping the original
33
+ // character order. Glyph shaping and bidi rendering remain the terminal's job.
24
34
  const visible = animated
25
- ? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth)
26
- : truncate(text, safeWidth);
27
- return _jsx(Text, { children: visible });
35
+ ? (direction === 'rtl' ? sliceDisplayFromEnd : sliceDisplay)(text, progress, width)
36
+ : truncate(text, width);
37
+ return _jsx(Text, { "aria-label": text, children: ascii ? toAsciiSafe(visible) : visible });
28
38
  }
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { textMuted } from '../theme.js';
4
+ import { useDisplay } from '../display-context.js';
4
5
  const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
5
6
  function LogoSpectrum() {
6
- return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
7
+ const { ascii } = useDisplay();
8
+ return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, "aria-hidden": true, children: ascii ? '##' : '██' }, color))) }));
7
9
  }
8
10
  export function Logo({ compact = false, width = 80 } = {}) {
11
+ const { ascii } = useDisplay();
9
12
  if (compact) {
10
13
  const spectrumColumns = Math.max(1, width - 10);
11
14
  const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns));
12
- return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, children: "\u2588" }, color))] }));
15
+ return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, "aria-hidden": true, children: ascii ? '#' : '█' }, color))] }));
13
16
  }
14
17
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color: textMuted, children: "Live public radio from around the world" })] }));
15
18
  }
@@ -2,10 +2,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  export function Menu({ items, selected, keyFor, render }) {
4
4
  const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
5
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
5
+ return (_jsx(Box, { flexDirection: "column", "aria-role": "list", children: items.map((item, position) => (_jsx(Box, { "aria-role": "listitem", "aria-state": { selected: position === activeIndex }, children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
6
6
  }
7
7
  export function Pointer({ active }) {
8
- return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
8
+ return _jsx(Text, { bold: active, "aria-hidden": true, children: active ? '> ' : ' ' });
9
9
  }
10
10
  function defaultMenuKey(item) {
11
11
  if (typeof item === 'string' || typeof item === 'number') {
@@ -15,5 +15,5 @@ export function ScreenHeader({ title, width, theme, subtitle, right }) {
15
15
  const rightText = right ? ` ${right}` : '';
16
16
  const ruleWidth = Math.max(1, width - displayWidth(safeTitle) - displayWidth(rightText) - 2);
17
17
  const rule = (ascii ? '-' : '─').repeat(ruleWidth);
18
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
18
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, "aria-hidden": true, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
19
19
  }
@@ -6,10 +6,12 @@ import { Menu, Pointer } from './Menu.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
7
  import { useDisplay } from '../display-context.js';
8
8
  import { AdaptiveMarquee } from './AdaptiveMarquee.js';
9
+ import { toAsciiSafe } from '../ascii.js';
9
10
  export function StationList({ stations, selected, theme, favorites, pageSize, width, emptyTitle = 'No stations found.', emptyHint = 'Try another view or clear active filters.', showCount = true }) {
10
- const { reduceMotion } = useDisplay();
11
+ const { reduceMotion, ascii } = useDisplay();
12
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
11
13
  if (stations.length === 0) {
12
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: emptyTitle }), _jsx(Text, { color: textMuted, children: emptyHint })] }));
14
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: a(emptyTitle) }), _jsx(Text, { color: textMuted, children: a(emptyHint) })] }));
13
15
  }
14
16
  const window = visibleWindow(stations, selected, pageSize);
15
17
  const rowWidth = Math.max(42, width - 4);
@@ -19,12 +21,12 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
19
21
  const favorite = favorites.has(`${station.provider}:${station.id}`);
20
22
  if (width < 42) {
21
23
  const compactNameWidth = Math.max(1, width - 2 - (favorite ? 2 : 0));
22
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }));
24
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", "aria-label": "Favorite", children: a(' ★') }) : null] }));
23
25
  }
24
26
  const stationNameWidth = favorite ? Math.max(1, nameWidth - 2) : nameWidth;
25
27
  const titleWidth = nameWidth + 2;
26
28
  const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
27
29
  const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
28
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: truncate(active ? selectedMetadata : standardMetadata, metaWidth) })] }));
30
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", "aria-label": "Favorite", children: a(' ★') }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: a(truncate(active ? selectedMetadata : standardMetadata, metaWidth)) })] }));
29
31
  } })] }));
30
32
  }
@@ -21,7 +21,7 @@ export function TopTabs({ tabs, active, theme, width, backendLabel }) {
21
21
  const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
22
22
  const visibleTabsWidth = tabsWidth(visibleTabs);
23
23
  const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0));
24
- return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
24
+ return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, "aria-label": `RadioCLI. Tabs: ${tabs.map(tab => `${tab.label}${tab.screen === active ? ' (selected)' : ''}`).join(', ')}. Audio output: ${backendLabel}.`, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: ascii ? '.' : '…' })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
25
25
  }
26
26
  function fitTabs(tabs, active, maxWidth) {
27
27
  const all = tabs.map(tab => ({ type: 'tab', tab }));
@@ -1,19 +1,16 @@
1
1
  import { createContext, useContext } from 'react';
2
+ import { resolveTerminalCapabilities } from '../platform/terminal.js';
2
3
  import { appBackground, panelBackground } from './theme.js';
3
4
  const opaqueBackgrounds = { app: appBackground, panel: panelBackground };
4
5
  const transparentBackgrounds = { app: undefined, panel: undefined };
5
- // Respect the NO_COLOR convention (https://no-color.org): any non-empty value
6
- // opts the user out of our forced colors, including the dark panel fills.
7
- export function noColorRequested(env = process.env) {
8
- return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0;
6
+ // Sixteen-color palettes cannot safely reproduce our dark fills. Let those
7
+ // terminals keep their own background while Ink quantizes the foregrounds.
8
+ export function resolveDisplayMode(settings, env = process.env, evidence = {}) {
9
+ const terminal = resolveTerminalCapabilities(env, { ...settings, ...evidence });
10
+ const backgrounds = settings.transparentBackground || terminal.colorLevel < 2 ? transparentBackgrounds : opaqueBackgrounds;
11
+ return { ...backgrounds, ascii: !terminal.unicode, reduceMotion: terminal.reduceMotion, screenReader: terminal.screenReader, colorLevel: terminal.colorLevel };
9
12
  }
10
- // Resolve the effective display mode from the user's settings and the NO_COLOR
11
- // environment so light terminals keep their own background.
12
- export function resolveDisplayMode(settings, env = process.env) {
13
- const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds;
14
- return { ...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion) };
15
- }
16
- const defaultMode = { ...opaqueBackgrounds, ascii: false, reduceMotion: false };
13
+ const defaultMode = resolveDisplayMode({}, {});
17
14
  export const DisplayContext = createContext(defaultMode);
18
15
  export function useDisplay() {
19
16
  return useContext(DisplayContext);
package/dist/ui/format.js CHANGED
@@ -1,3 +1,7 @@
1
+ // Use Ink's width rules on supported Node runtimes, including emoji sequences
2
+ // and text/emoji presentation selectors. Retain code-point fallback for hosts
3
+ // without Intl.Segmenter, where full grapheme rendering is unavailable.
4
+ const rendererWidth = typeof Intl.Segmenter === 'function' ? (await import('string-width')).default : undefined;
1
5
  export function stationLocation(station) {
2
6
  return [station.city, station.state, station.country].filter(Boolean).join(', ') || station.countryCode || 'Unknown origin';
3
7
  }
@@ -35,6 +39,8 @@ export function truncate(value, length) {
35
39
  return `${result}…`;
36
40
  }
37
41
  export function displayWidth(value) {
42
+ if (rendererWidth)
43
+ return rendererWidth(value);
38
44
  let width = 0;
39
45
  for (const grapheme of graphemes(value)) {
40
46
  if (/^\p{Mark}+$/u.test(grapheme))
@@ -54,12 +60,14 @@ export function sliceDisplay(value, start, length) {
54
60
  let skippedWidth = 0;
55
61
  let outputWidth = 0;
56
62
  let result = '';
63
+ let started = false;
57
64
  for (const grapheme of graphemes(value)) {
58
65
  const graphemeWidth = displayWidth(grapheme);
59
- if (skippedWidth + graphemeWidth <= safeStart) {
66
+ if (!started && skippedWidth + graphemeWidth <= safeStart) {
60
67
  skippedWidth += graphemeWidth;
61
68
  continue;
62
69
  }
70
+ started = true;
63
71
  if (outputWidth + graphemeWidth > safeLength)
64
72
  break;
65
73
  result += grapheme;
@@ -67,15 +75,40 @@ export function sliceDisplay(value, start, length) {
67
75
  }
68
76
  return result;
69
77
  }
78
+ /** Clip from the logical end for rightward scrolling, without reversing text. */
79
+ export function sliceDisplayFromEnd(value, start, length) {
80
+ const safeStart = Math.max(0, Math.floor(start));
81
+ const safeLength = Math.max(0, Math.floor(length));
82
+ if (safeLength === 0)
83
+ return '';
84
+ let skippedWidth = 0;
85
+ let outputWidth = 0;
86
+ const parts = graphemes(value);
87
+ const result = [];
88
+ for (let index = parts.length - 1; index >= 0; index--) {
89
+ const grapheme = parts[index];
90
+ const graphemeWidth = displayWidth(grapheme);
91
+ // Skip the entire cluster when the moving edge lands inside a wide glyph.
92
+ if (skippedWidth < safeStart) {
93
+ skippedWidth += graphemeWidth;
94
+ continue;
95
+ }
96
+ if (outputWidth + graphemeWidth > safeLength)
97
+ break;
98
+ result.push(grapheme);
99
+ outputWidth += graphemeWidth;
100
+ }
101
+ return result.reverse().join('');
102
+ }
70
103
  export function removeLastGrapheme(value) {
71
104
  const parts = graphemes(value);
72
105
  parts.pop();
73
106
  return parts.join('');
74
107
  }
108
+ const graphemeSegmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
75
109
  function graphemes(value) {
76
- const Segmenter = Intl.Segmenter;
77
- if (Segmenter) {
78
- return [...new Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].map(item => item.segment);
110
+ if (graphemeSegmenter) {
111
+ return [...graphemeSegmenter.segment(value)].map(item => item.segment);
79
112
  }
80
113
  return Array.from(value);
81
114
  }
@@ -20,7 +20,7 @@ export const keyHelpSections = [
20
20
  { keys: ', / . or F7/F9', description: 'Previous / next station' },
21
21
  { keys: '+ / -', description: 'Volume up / down' },
22
22
  { keys: 'm', description: 'Mute / unmute' },
23
- { keys: 'f', description: 'Favorite the station' },
23
+ { keys: 'f / Ctrl+F', description: 'Favorite the station (Ctrl+F while typing in Search)' },
24
24
  { keys: 'O', description: 'Open station homepage' },
25
25
  { keys: 'y', description: 'Copy stream URL' },
26
26
  { keys: 's', description: 'Sleep timer (Now Playing)' },
@@ -70,7 +70,7 @@ export const commandHelp = [
70
70
  { name: 'ascii', args: '<on|off>', description: 'ASCII-safe display' },
71
71
  { name: 'motion', args: '<on|off>', description: 'Reduce motion' },
72
72
  { name: 'background', args: '<on|off>', description: 'Transparent background' },
73
- { name: 'airplay', description: 'Open AirPlay settings' },
73
+ { name: 'airplay', description: 'Open experimental AirPlay settings' },
74
74
  { name: 'learn', args: '<previous|play|next>', description: 'Learn a media key' },
75
75
  { name: 'keys', args: '[reset]', description: 'Show or reset learned media keys' },
76
76
  { name: 'favorite', description: 'Favorite the current station (alias :fav)' },
@@ -78,13 +78,14 @@ export const commandHelp = [
78
78
  { name: 'map', description: 'Open the world map' },
79
79
  { name: 'stats', description: 'Open listening stats' },
80
80
  { name: 'settings', description: 'Open settings' },
81
- { name: 'export', args: '[backup.json]', description: 'Export preferences and library' },
82
- { name: 'import', args: '<backup.json>', description: 'Restore preferences and library' },
81
+ { name: 'import', args: '<stream-url> [name]', description: 'Add a custom station and discover its details' },
82
+ { name: 'export', args: '[backup.json]', description: 'Back up preferences and library' },
83
+ { name: 'restore', args: '<backup.json>', description: 'Restore preferences and library' },
83
84
  { name: 'update', description: 'Show the install command for the latest release' },
84
85
  { name: 'stop', description: 'Stop playback' },
85
86
  { name: 'help', description: 'Open this help' }
86
87
  ];
87
- export const helpItemCount = keyHelpSections.reduce((total, section) => total + 1 + section.entries.length, 0) +
88
+ const helpItemCount = keyHelpSections.reduce((total, section) => total + 1 + section.entries.length, 0) +
88
89
  1 +
89
90
  commandHelp.length;
90
91
  // Primary command names plus the aliases the executor accepts, used for
@@ -103,7 +104,6 @@ export const commandNames = [
103
104
  'airplay-code',
104
105
  'airplay-settings',
105
106
  'backup',
106
- 'restore',
107
107
  ...commandHelp.map(entry => entry.name)
108
108
  ].sort((a, b) => a.localeCompare(b));
109
109
  // Complete a partial command name to the longest shared prefix of its matches.
@@ -134,3 +134,6 @@ function longestCommonPrefix(values) {
134
134
  }
135
135
  return prefix;
136
136
  }
137
+ export function commandHelpForAirPlay(visible = true) { return commandHelp.filter(command => visible || !command.name.startsWith('airplay')); }
138
+ export function commandNamesForAirPlay(visible = true) { return commandNames.filter(name => visible || !name.startsWith('airplay')); }
139
+ export function helpItemCountForAirPlay(visible = true) { return helpItemCount - commandHelp.length + commandHelpForAirPlay(visible).length; }
package/dist/ui/layout.js CHANGED
@@ -18,8 +18,11 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
18
18
  const reservedFooterRows = Math.min(desiredFooterRows, Math.max(0, safeRows - topRows - 1));
19
19
  const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
20
20
  const mapMode = frameWidth >= 88 && contentRows >= 24 ? 'full' : 'compact';
21
- const stationRows = clamp(contentRows - 6, 1, 48);
22
- const countryRows = clamp(contentRows - 5, 1, 64);
21
+ // Derive list windows from the live terminal height. Capping these values
22
+ // leaves the lower half of tall/full-screen terminals unused while the
23
+ // selection continues scrolling through rows that could already be visible.
24
+ const stationRows = Math.max(1, contentRows - 6);
25
+ const countryRows = Math.max(1, contentRows - 5);
23
26
  return {
24
27
  columns: safeColumns,
25
28
  rows: safeRows,
@@ -13,7 +13,7 @@ export function fullStatusFooterRows(screen, message, footerMessage, playbackSta
13
13
  { key: 'playback', text: footerMessage ?? playbackStatus ?? ' ' }
14
14
  ];
15
15
  }
16
- export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen, settingsPage = 'root' }) {
16
+ export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, airPlaySupported = true, playbackBackend, screen, settingsPage = 'root' }) {
17
17
  if (capturingTransportAction) {
18
18
  return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
19
19
  }
@@ -24,7 +24,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
24
24
  return '↑/↓ move · Enter open · number jump · l location · : command';
25
25
  }
26
26
  if (screen === 'search' && editingSearch) {
27
- return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
27
+ return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Ctrl+F favorite · Esc finish';
28
28
  }
29
29
  if (screen === 'search') {
30
30
  return '/ edit query · ↑/↓ or n/p move · Enter tune · f favorite · b Overview';
@@ -59,7 +59,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
59
59
  }
60
60
  if (screen === 'settings') {
61
61
  return settingsPage === 'root'
62
- ? '↑/↓ choose · Enter open · o output · a AirPlay · b Overview'
62
+ ? `↑/↓ choose · Enter open · o output${airPlaySupported ? ' · a AirPlay' : ''} · b Overview`
63
63
  : '↑/↓ choose · Enter change · b Settings · shortcuts still work';
64
64
  }
65
65
  if (screen === 'airplay-settings') {