@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
@@ -0,0 +1,137 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import { rightToLeftPattern, strongDirectionPattern } from './bidi-ranges.js';
3
+ import { displayWidth } from './format.js';
4
+ // Ink writes complete VT sequences. Keep OSC payloads (titles, hyperlinks) out
5
+ // of text analysis, and treat cursor controls as boundaries between text runs.
6
+ const controls = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[78@-_])|[\u0000-\u001F\u007F]/gu;
7
+ const sgr = /^\u001B\[[\d;:]*m$/u;
8
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
9
+ /**
10
+ * Yoga's boxes become a single terminal row, not independently positioned
11
+ * fields. Guard fixed fields and borders against terminal-side shaping in
12
+ * mixed RTL rows by explicitly positioning the columns around each RTL run.
13
+ * Apple Terminal's xterm profile also gets Unicode isolates to contain stored
14
+ * row reordering independently of cursor reports. Keep those opt-in: older
15
+ * Linux/BSD consoles may print unsupported format marks.
16
+ * Ordinary LTR output stays byte-for-byte unchanged.
17
+ *
18
+ * Apply only to Ink's interactive stdout, after its cell layout/tokenization:
19
+ * inserting zero-width bidi marks into Text would itself consume Ink cells.
20
+ */
21
+ export function rtlSafeOutput(stdout, env = process.env) {
22
+ const isolate = env.TERM_PROGRAM === 'Apple_Terminal' && /^xterm(?:-|$)/i.test(env.TERM ?? '');
23
+ let column = 0;
24
+ let savedColumn;
25
+ const position = (control) => {
26
+ if (control === '\n' || control === '\r')
27
+ column = 0;
28
+ else if (control === '\b' && column !== undefined)
29
+ column = Math.max(0, column - 1);
30
+ else if (control === '\t' && column !== undefined)
31
+ column = (Math.floor(column / 8) + 1) * 8;
32
+ else if (control === '\u001B7' || control === '\u001B[s')
33
+ savedColumn = column;
34
+ else if (control === '\u001B8' || control === '\u001B[u')
35
+ column = savedColumn;
36
+ else {
37
+ const csi = /^\u001B\[([\d;]*)([G`CDaHEFf])$/u.exec(control);
38
+ if (!csi)
39
+ return;
40
+ const parameters = csi[1].split(';').map(value => Number(value) || 1);
41
+ const command = csi[2];
42
+ if (command === 'G' || command === '`')
43
+ column = parameters[0] - 1;
44
+ else if (command === 'H' || command === 'f')
45
+ column = (parameters[1] ?? 1) - 1;
46
+ else if (command === 'E' || command === 'F')
47
+ column = 0;
48
+ else if (column !== undefined)
49
+ column = Math.max(0, column + (command === 'D' ? -1 : 1) * parameters[0]);
50
+ }
51
+ };
52
+ const text = (span, endsRow = false) => {
53
+ const plain = stripVTControlCharacters(span);
54
+ const hasRtl = rightToLeftPattern.test(plain);
55
+ // Animation frames contain many art-only rows. Their next newline resets
56
+ // the column, so do not remeasure thousands of unchanged visualizer cells.
57
+ if (!hasRtl && endsRow)
58
+ return span;
59
+ const startColumn = column;
60
+ if (column !== undefined)
61
+ column += displayWidth(plain);
62
+ if (startColumn === undefined || !hasRtl)
63
+ return span;
64
+ const runs = [];
65
+ let first;
66
+ let last;
67
+ let offset = startColumn;
68
+ const finish = () => {
69
+ if (first && last)
70
+ runs.push({ first, last });
71
+ first = undefined;
72
+ last = undefined;
73
+ };
74
+ // Segment the complete visible text: SGR can occur inside a combining or
75
+ // emoji cluster. Map its UTF-16 edges back to the original styled span.
76
+ const indices = [];
77
+ let from = 0;
78
+ for (const match of span.matchAll(new RegExp(controls))) {
79
+ for (let index = from; index < match.index; index++)
80
+ indices.push(index);
81
+ from = match.index + match[0].length;
82
+ }
83
+ for (let index = from; index < span.length; index++)
84
+ indices.push(index);
85
+ // Neutral characters between RTL words belong to the same run, preserving
86
+ // Arabic word order without absorbing trailing padding or fixed fields.
87
+ for (const part of segmenter.segment(plain)) {
88
+ const width = displayWidth(part.segment);
89
+ const strong = strongDirectionPattern.exec(part.segment)?.[0];
90
+ if (strong && rightToLeftPattern.test(strong)) {
91
+ first ??= { index: indices[part.index], column: offset };
92
+ last = { index: indices[part.index + part.segment.length - 1] + 1, column: offset + width };
93
+ }
94
+ else if (strong)
95
+ finish();
96
+ offset += width;
97
+ }
98
+ finish();
99
+ const at = (value) => `\u001B[${value + 1}G`;
100
+ let result = '';
101
+ from = 0;
102
+ for (const run of runs) {
103
+ result += span.slice(from, run.first.index) + at(run.first.column)
104
+ + (isolate ? '\u2067' : '') + span.slice(run.first.index, run.last.index)
105
+ + (isolate ? '\u2069' : '') + at(run.last.column);
106
+ from = run.last.index;
107
+ }
108
+ return result + span.slice(from);
109
+ };
110
+ const transform = (value) => {
111
+ let result = '';
112
+ let from = 0;
113
+ for (const match of value.matchAll(new RegExp(controls))) {
114
+ if (sgr.test(match[0]))
115
+ continue;
116
+ result += text(value.slice(from, match.index), match[0] === '\n' || match[0] === '\r') + match[0];
117
+ position(match[0]);
118
+ from = match.index + match[0].length;
119
+ }
120
+ return result + text(value.slice(from));
121
+ };
122
+ const write = (chunk, ...args) => {
123
+ // Ink frames are strings. Preserve other writes and their encoding/callback
124
+ // contract; resume anchoring only after a known cursor position or newline.
125
+ if (typeof chunk !== 'string')
126
+ column = undefined;
127
+ return Reflect.apply(stdout.write, stdout, [typeof chunk === 'string' ? transform(chunk) : chunk, ...args]);
128
+ };
129
+ return new Proxy(stdout, {
130
+ get(target, key) {
131
+ if (key === 'write')
132
+ return write;
133
+ const value = Reflect.get(target, key, target);
134
+ return typeof value === 'function' ? value.bind(target) : value;
135
+ }
136
+ });
137
+ }
@@ -1,6 +1,6 @@
1
1
  export const homeItems = [
2
2
  { screen: 'now-playing', label: 'Playing', detail: 'Receiver display and controls' },
3
- { screen: 'library', label: 'Library', detail: 'Favorites, recent stations, imported streams' },
3
+ { screen: 'library', label: 'Library', detail: 'Favorites, imported streams, recent stations' },
4
4
  { screen: 'explore', label: 'Explore', detail: 'Move a map cursor through geotagged stations' },
5
5
  { screen: 'search', label: 'Search', detail: 'Find stations by name, genre, language, place' },
6
6
  { screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
@@ -54,7 +54,7 @@ export const settingsGroups = [
54
54
  description: 'Back up or restore preferences and saved stations',
55
55
  items: [
56
56
  'Export preferences and library',
57
- 'Import preferences and library'
57
+ 'Restore preferences and library'
58
58
  ]
59
59
  },
60
60
  {
@@ -91,11 +91,17 @@ export const settingsGroups = [
91
91
  ];
92
92
  export const settingsItems = settingsGroups.flatMap(group => group.items);
93
93
  export const settingsRootItems = settingsGroups.map(group => group.label);
94
- export function settingsGroup(page) {
95
- return settingsGroups.find(group => group.id === page);
94
+ export function settingsGroupsForAirPlay(visible = true) {
95
+ return settingsGroups.map(group => ({ ...group,
96
+ description: !visible && group.id === 'playback' ? 'Output, volume, and startup behavior' : group.description,
97
+ items: group.items.filter(item => visible || item !== 'AirPlay receiver')
98
+ }));
96
99
  }
97
- export function settingsItemsForPage(page) {
98
- return page === 'root' ? settingsRootItems : settingsGroup(page)?.items ?? [];
100
+ export function settingsGroup(page, airPlayVisible = true) {
101
+ return settingsGroupsForAirPlay(airPlayVisible).find(group => group.id === page);
102
+ }
103
+ export function settingsItemsForPage(page, airPlayVisible = true) {
104
+ return page === 'root' ? settingsRootItems : settingsGroup(page, airPlayVisible)?.items ?? [];
99
105
  }
100
106
  export function settingsPageForRootItem(item) {
101
107
  return settingsGroups.find(group => group.label === item)?.id;
@@ -12,7 +12,7 @@ export const primaryScreens = [
12
12
  export const screenMetadata = {
13
13
  home: { tabLabel: 'Overview', title: 'Overview', description: 'Your listening at a glance' },
14
14
  'now-playing': { tabLabel: 'Playing', title: 'Now playing', description: 'Receiver and playback controls' },
15
- library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, recents, and imported streams' },
15
+ library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, imported streams, and recents' },
16
16
  explore: { tabLabel: 'Explore', title: 'Explore', description: 'Discover stations on the world map' },
17
17
  search: { tabLabel: 'Search', title: 'Search', description: 'Find stations, genres, languages, or places' },
18
18
  countries: { tabLabel: 'Countries', title: 'Countries', description: 'Browse stations by country' },
@@ -2,16 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { ScreenHeader } from '../components/ScreenHeader.js';
4
4
  import { truncate } from '../format.js';
5
+ import { useDisplay } from '../display-context.js';
6
+ import { toAsciiSafe } from '../ascii.js';
5
7
  import { textDim, textMuted, themeAccent } from '../theme.js';
6
8
  export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
9
+ const { ascii } = useDisplay();
10
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
7
11
  const accent = themeAccent(theme);
8
12
  const lineWidth = Math.max(24, width - 4);
9
13
  const promptActive = isAirPlayCodePromptActive(playback);
10
14
  const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
11
15
  const masked = code.length > 0 ? '*'.repeat(code.length) : '';
12
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
16
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: a(truncate(receiverName, Math.max(8, lineWidth - 10))) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
13
17
  ? 'The receiver is asking for a code now.'
14
- : 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: "Enter submits \u00B7 Backspace edits \u00B7 Esc returns to AirPlay" })] })] }));
18
+ : 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: a('Enter submits · Backspace edits · Esc returns to AirPlay') })] })] }));
15
19
  }
16
20
  export function isAirPlayCodePromptActive(playback) {
17
21
  return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
@@ -7,20 +7,24 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
7
7
  import { truncate } from '../format.js';
8
8
  import { textDim, textMuted, themeAccent } from '../theme.js';
9
9
  import { visibleWindow } from '../list-window.js';
10
- export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width, height }) {
10
+ import { useDisplay } from '../display-context.js';
11
+ import { toAsciiSafe } from '../ascii.js';
12
+ export function AirPlaySettingsScreen({ platform = process.platform, selected, settings, backends, devices, theme, width, height }) {
13
+ const { ascii } = useDisplay();
14
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
11
15
  const accent = themeAccent(theme);
12
- const availability = airPlayAvailability(backends, devices);
16
+ const availability = airPlayAvailability(backends, devices, platform);
13
17
  const selectedDevice = selectedAirPlayDevice(settings, devices);
14
18
  const selectedMissing = Boolean(settings.preferredAirPlayDevice && !selectedDevice);
15
19
  const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
16
20
  const lineWidth = Math.max(24, width - 4);
17
21
  const preferredOutput = audioOutputLabel(settings.preferredBackend);
18
22
  const deviceWindow = visibleWindow(devices, selected, height ? Math.max(1, height - 9) : Math.max(1, devices.length));
19
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: deviceWindow.items, selected: selected - deviceWindow.start, keyFor: device => device.id, render: (device, _index, active) => {
23
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), a(' · Streaming: '), _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), a(' · Receivers: '), _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: a(truncate(availability.detail, lineWidth)) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: a(truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10))) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", a(truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31)))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: deviceWindow.items, selected: selected - deviceWindow.start, keyFor: device => device.id, render: (device, _index, active) => {
20
24
  const isSelected = device.id === settings.preferredAirPlayDevice;
21
25
  const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
22
26
  const selectedLabel = isSelected ? ' selected' : '';
23
- const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
24
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color: textMuted, children: [" \u00B7 ", detail] })] }));
25
- } })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
27
+ const detail = truncate(airPlayDeviceDetail(device, platform), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
28
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(name) }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsx(Text, { color: textMuted, children: a(` · ${detail}`) })] }));
29
+ } })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers') })] })] }));
26
30
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
2
3
  import { Box, Text } from 'ink';
3
4
  import { draftNextOccurrence } from '../alarm-editor.js';
4
- import { nextOccurrenceForAlarm } from '../../alarms/schedule.js';
5
+ import { AlarmOccurrenceCache } from '../../alarms/occurrence-cache.js';
5
6
  import { stationKey } from '../../storage/store.js';
6
7
  import { visibleWindow } from '../list-window.js';
7
8
  import { ScreenHeader } from '../components/ScreenHeader.js';
@@ -15,10 +16,12 @@ export function AlarmsScreen({ alarms, selected, runtime, verification, deleting
15
16
  const { ascii, reduceMotion } = useDisplay();
16
17
  const a = (value) => ascii ? toAsciiSafe(value) : value;
17
18
  const mode = requestedMode ?? (width < 34 ? 'micro' : width < 68 ? 'compact' : 'full');
19
+ const occurrenceCache = useMemo(() => new AlarmOccurrenceCache(), []);
18
20
  if (verification)
19
21
  return _jsx(AlarmVerificationScreen, { report: verification, theme: theme, width: width, height: height });
20
22
  const status = schedulerStatus(runtime, mode);
21
- const next = alarms.map(alarm => ({ alarm, occurrence: nextOccurrenceForAlarm(alarm, new Date()) })).filter(item => item.occurrence).sort((a, b) => a.occurrence.getTime() - b.occurrence.getTime())[0];
23
+ const now = new Date();
24
+ const next = alarms.map(alarm => ({ alarm, occurrence: occurrenceCache.next(alarm, now) })).filter(item => item.occurrence).sort((a, b) => a.occurrence.getTime() - b.occurrence.getTime())[0];
22
25
  const noticeRows = height >= 11 ? 2 : 1;
23
26
  const rows = Math.max(1, height - 5 - noticeRows);
24
27
  const items = [{ kind: 'create' }, ...alarms.map(alarm => ({ kind: 'alarm', alarm })), { kind: 'verify' }];
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
+ import { formatExploreCursor } from '../app-state.js';
4
5
  import { StationList } from '../components/StationList.js';
5
6
  import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
7
  import { computeExploreMapLayout } from '../explore-map-layout.js';
@@ -14,7 +15,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, er
14
15
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, mapOffsetX, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
15
16
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
16
17
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
17
- return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
18
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", "aria-label": `World map cursor: ${formatExploreCursor(cursor)}`, children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
18
19
  ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…')
19
20
  : error
20
21
  ? truncate(ascii ? toAsciiSafe(error) : error, Math.max(1, listPanelWidth - 2))
@@ -2,10 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { textMuted, themeAccent } from '../theme.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { commandHelp, keyHelpSections } from '../help-content.js';
5
+ import { commandHelpForAirPlay, keyHelpSections } from '../help-content.js';
6
6
  import { padDisplayEnd, truncate } from '../format.js';
7
7
  import { visibleWindow } from '../list-window.js';
8
- export function HelpScreen({ theme, width, height, selected }) {
8
+ import { useDisplay } from '../display-context.js';
9
+ import { toAsciiSafe } from '../ascii.js';
10
+ export function HelpScreen({ theme, width, height, selected, airPlaySupported = true }) {
11
+ const { ascii } = useDisplay();
12
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
9
13
  const accent = themeAccent(theme);
10
14
  const keyColumnWidth = 16;
11
15
  const lineWidth = Math.max(28, width - 2);
@@ -15,12 +19,12 @@ export function HelpScreen({ theme, width, height, selected }) {
15
19
  ...section.entries.map(entry => ({ key: `${section.title}-${entry.keys}`, ...entry }))
16
20
  ]),
17
21
  { key: 'commands', heading: 'Commands (press : then type)' },
18
- ...commandHelp.map(command => ({
22
+ ...commandHelpForAirPlay(airPlaySupported).map(command => ({
19
23
  key: `command-${command.name}`,
20
24
  keys: `:${command.name}${command.args ? ` ${command.args}` : ''}`,
21
25
  description: command.description
22
26
  }))
23
27
  ];
24
28
  const window = visibleWindow(rows, selected, Math.max(3, height - 4));
25
- return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth) }), _jsx(Text, { color: textMuted, children: truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)) })] }, row.key))) }), _jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", rows.length, " \u00B7 \u2191/\u2193 scroll"] })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)) }), _jsx(Text, { color: textMuted, children: a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))) })] }, row.key))) }), _jsx(Text, { color: textMuted, children: a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`) })] }));
26
30
  }
@@ -5,7 +5,11 @@ import { Menu, Pointer } from '../components/Menu.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
7
  import { displayWidth } from '../format.js';
8
+ import { useDisplay } from '../display-context.js';
9
+ import { toAsciiSafe } from '../ascii.js';
8
10
  export function HomeScreen({ selected, theme, library }) {
11
+ const { ascii } = useDisplay();
12
+ const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`;
9
13
  const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2;
10
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
14
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textMuted, children: ascii ? toAsciiSafe(summary) : summary }) })] }));
11
15
  }
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
18
18
  const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
19
19
  const topWidth = mode === 'full' ? 50 : contentWidth;
20
20
  const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", "aria-hidden": true, children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
22
22
  }
23
23
  function MapLine({ row, theme }) {
24
24
  const { ascii } = useDisplay();
@@ -14,7 +14,7 @@ const receiverPaddingX = 3;
14
14
  const receiverPaddingY = 1;
15
15
  export function NowPlayingScreen({ station, playback, metadata, theme, favorite, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
16
16
  const pulse = useReceiverPulse();
17
- const { panel: panelBackground, ascii } = useDisplay();
17
+ const { panel: panelBackground, ascii, screenReader } = useDisplay();
18
18
  // In ASCII mode route every rendered string through the glyph mapper so no
19
19
  // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
20
20
  const a = (value) => (ascii ? toAsciiSafe(value) : value);
@@ -28,7 +28,7 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
28
28
  // visualization equally separated from the header and track title.
29
29
  const visualizerGutterRows = 1;
30
30
  const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
31
- const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
31
+ const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
32
32
  const receiverState = playback.state.toUpperCase();
33
33
  const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
34
34
  const stationIdentity = receiverStationIdentity(station, identityWidth);
@@ -42,9 +42,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
42
42
  : infoFallback;
43
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", 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, children: receiverState })] }), _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
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: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
46
46
  ? renderSegments(row.segments)
47
- : row.text }, index))) }), _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, 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] })] }));
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
48
  }
49
49
  function asciifyVisualRow(row) {
50
50
  return {
@@ -5,10 +5,12 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { panelBorder, textMuted, themeAccent } from '../theme.js';
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { truncate } from '../format.js';
8
+ import { toAsciiSafe } from '../ascii.js';
8
9
  export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
9
10
  const { panel: panelBackground, ascii } = useDisplay();
11
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
10
12
  const contentWidth = Math.max(40, width);
11
13
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
12
14
  const inputTextWidth = Math.max(8, inputWidth - 8);
13
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children: "Searching station directories\u2026" }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
15
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: a(editing ? '› ' : ' ') }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)) })] }), loading ? _jsx(Text, { color: textMuted, children: a('Searching station directories…') }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
14
16
  }
@@ -4,21 +4,25 @@ 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, settingsGroups, settingsItemsForPage } from '../screen-items.js';
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';
11
11
  import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
12
12
  import { updateStatusText } from '../../update-check.js';
13
- export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
13
+ import { useDisplay } from '../display-context.js';
14
+ import { toAsciiSafe } from '../ascii.js';
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
+ const { ascii } = useDisplay();
17
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
14
18
  const accent = themeAccent(theme);
15
19
  const lineWidth = Math.max(32, width - 4);
16
- const group = settingsGroup(page);
17
- const pageItems = settingsItemsForPage(page);
20
+ const group = settingsGroup(page, airPlaySupported);
21
+ const pageItems = settingsItemsForPage(page, airPlaySupported);
18
22
  const menuWindow = settingsMenuWindow(pageItems, selected, height);
19
23
  const selectedItem = pageItems[selected];
20
24
  const detail = page === 'root' && selectedItem !== 'Check for updates'
21
- ? settingsGroupsDescription(selectedItem)
25
+ ? settingsGroupsDescription(selectedItem, airPlaySupported)
22
26
  : settingDetail({
23
27
  item: selectedItem,
24
28
  appVersion,
@@ -33,12 +37,12 @@ export function SettingsScreen({ page = 'root', selected, settings, appVersion,
33
37
  const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
34
38
  const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
35
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) => {
36
- const label = settingLabel(item, updateCheck, appVersion);
40
+ const label = settingLabel(item, updateCheck, appVersion, airPlaySupported);
37
41
  const value = page === 'root'
38
- ? settingsRootValue(item)
39
- : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
40
- return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] }));
41
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
42
+ ? settingsRootValue(item, airPlaySupported)
43
+ : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion, platform);
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
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: a(truncate(detail, lineWidth)) })] })] }));
42
46
  }
43
47
  function settingsMenuWindow(items, selected, height) {
44
48
  const reservedRows = 6;
@@ -52,17 +56,17 @@ function settingsMenuWindow(items, selected, height) {
52
56
  selectedOffset: clampedSelected - pageStart
53
57
  };
54
58
  }
55
- function settingsRootValue(item) {
56
- const group = settingsGroupByLabel(item);
59
+ function settingsRootValue(item, airPlayVisible) {
60
+ const group = settingsGroupByLabel(item, airPlayVisible);
57
61
  return group ? `${group.items.length} settings ›` : undefined;
58
62
  }
59
- function settingsGroupByLabel(label) {
60
- return settingsGroups.find(group => group.label === label);
63
+ function settingsGroupByLabel(label, airPlayVisible) {
64
+ return settingsGroupsForAirPlay(airPlayVisible).find(group => group.label === label);
61
65
  }
62
- function settingsGroupsDescription(label) {
63
- 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.' : '';
64
68
  }
65
- export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion) {
69
+ export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion, platform = process.platform) {
66
70
  switch (item) {
67
71
  case 'Cycle display color':
68
72
  return settings.theme;
@@ -77,7 +81,7 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
77
81
  case 'Audio output':
78
82
  return audioOutputSettingValue(settings, diagnostics, backends);
79
83
  case 'AirPlay receiver':
80
- return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
84
+ return airPlayReceiverSettingValue(settings, airPlayDevices, backends, platform);
81
85
  case 'Mute or unmute':
82
86
  if (!playbackBackendCapabilities(diagnostics.backend).supportsMute) {
83
87
  return 'requires mpv';
@@ -111,13 +115,16 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
111
115
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
112
116
  case 'Export preferences and library':
113
117
  return 'JSON backup';
114
- case 'Import preferences and library':
118
+ case 'Restore preferences and library':
115
119
  return 'restore JSON backup';
116
120
  default:
117
121
  return undefined;
118
122
  }
119
123
  }
120
- export function settingLabel(item, updateCheck, currentVersion) {
124
+ export function settingLabel(item, updateCheck, currentVersion, airPlaySupported = true) {
125
+ if (item === 'AirPlay receiver' && !airPlaySupported) {
126
+ return 'AirPlay receiver (unavailable)';
127
+ }
121
128
  if (item === 'Check for updates') {
122
129
  return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
123
130
  }
@@ -145,7 +152,7 @@ function settingDetail(input) {
145
152
  if (item === 'Automatically check for updates') {
146
153
  return 'When enabled, RadioCLI checks the registry once whenever the app launches.';
147
154
  }
148
- if (item === 'Export preferences and library' || item === 'Import preferences and library') {
155
+ if (item === 'Export preferences and library' || item === 'Restore preferences and library') {
149
156
  return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
150
157
  }
151
158
  if (item === 'Share favorite votes with Radio Browser') {
@@ -6,6 +6,7 @@ import { panelBorder, textMuted, themeAccent, themeContributionColors } from '..
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { truncate } from '../format.js';
9
+ import { toAsciiSafe } from '../ascii.js';
9
10
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
11
  const dayLabelWidth = 5;
11
12
  // Two terminal columns are approximately one row high in common monospace
@@ -15,6 +16,7 @@ const heatmapCell = ' ';
15
16
  const heatmapGap = '';
16
17
  export function StatsScreen({ library, theme, width, height }) {
17
18
  const { panel: panelBackground, ascii } = useDisplay();
19
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
18
20
  const stats = computeListeningStats(library.activity.sessions);
19
21
  const contentWidth = Math.max(20, width - 4);
20
22
  const graph = buildContributionGraph(stats.days, contentWidth, height);
@@ -24,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
24
26
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
25
27
  const favoriteWidth = Math.max(8, metricWidth - 18);
26
28
  const compact = height < 30;
27
- 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: _jsxs(Text, { color: themeAccent(theme), bold: true, children: ["Activity \u2014 ", graph.year] }) }), _jsxs(Box, { flexDirection: "column", 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', 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), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
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" })] })] })] }));
28
30
  }
29
31
  function renderContributionCells(cells, graphColors, cellGap) {
30
32
  const runs = [];