@ciphore/radiocli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. package/package.json +6 -1
@@ -1,5 +1,6 @@
1
1
  import { mediaActionLabel } from './app-state.js';
2
2
  import { playbackBackendCapabilities } from '../player/backend-install.js';
3
+ import { displayWidth } from './format.js';
3
4
  export function fullFooterRowCount(screen) {
4
5
  return screen === 'now-playing' ? 3 : 4;
5
6
  }
@@ -12,7 +13,7 @@ export function fullStatusFooterRows(screen, message, footerMessage, playbackSta
12
13
  { key: 'playback', text: footerMessage ?? playbackStatus ?? ' ' }
13
14
  ];
14
15
  }
15
- export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen }) {
16
+ export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen, settingsPage = 'root' }) {
16
17
  if (capturingTransportAction) {
17
18
  return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
18
19
  }
@@ -57,7 +58,9 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
57
58
  return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b Overview';
58
59
  }
59
60
  if (screen === 'settings') {
60
- return 'Enter change selected · g Radio Garden · l location · x skip · o output · a AirPlay · r health · b Overview';
61
+ return settingsPage === 'root'
62
+ ? '↑/↓ choose · Enter open · o output · a AirPlay · b Overview'
63
+ : '↑/↓ choose · Enter change · b Settings · shortcuts still work';
61
64
  }
62
65
  if (screen === 'airplay-settings') {
63
66
  return canEnterAirPlayCode
@@ -70,6 +73,18 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
70
73
  if (screen === 'stats') {
71
74
  return 'b Overview';
72
75
  }
76
+ if (screen === 'alarms') {
77
+ return '↑/↓ or j/k/p move · Enter choose/create/edit · n new · Space toggle · g Guard · x twice delete · t station test · r repair · b Overview';
78
+ }
79
+ if (screen === 'alarm-editor') {
80
+ return '↑/↓ field · ←/→ adjust · Enter open/control · Ctrl+S save · Esc cancel';
81
+ }
82
+ if (screen === 'alarm-picker') {
83
+ return '↑/↓ choose · Enter select · Esc editor';
84
+ }
85
+ if (screen === 'alarm-ringing') {
86
+ return 'Enter keep playing · Space snooze · ↑/↓ alarm · b return';
87
+ }
73
88
  if (screen === 'help') {
74
89
  return '↑/↓ scroll · [/] page · ? or b close · : command';
75
90
  }
@@ -84,3 +99,106 @@ export function microPlaybackControlsText(playbackBackend) {
84
99
  }
85
100
  return 'space pause · +/- volume · ,/. station';
86
101
  }
102
+ export function balancedFooterLegendRows(pageText, globalText, width, rowCount = 2, lastRowReservedWidth = 0) {
103
+ const safeWidth = Math.max(1, width);
104
+ const safeRowCount = Math.max(1, rowCount);
105
+ const rowWidths = Array.from({ length: safeRowCount }, (_, index) => index === safeRowCount - 1 ? Math.max(1, safeWidth - lastRowReservedWidth) : safeWidth);
106
+ const pageTokens = footerTokens(pageText).map(compactLegendToken);
107
+ const globalTokens = footerTokens(globalText).map(compactLegendToken);
108
+ const regular = fitLegendRows(pageTokens, globalTokens, rowWidths);
109
+ const compressed = fitLegendRows(pageTokens, globalTokens.map(compactGlobalToken), rowWidths);
110
+ if (regular && (!compressed || regular.retainedPageTokens >= compressed.retainedPageTokens))
111
+ return regular.rows;
112
+ if (compressed)
113
+ return compressed.rows;
114
+ return packWholeTokens(globalTokens.map(compactGlobalToken), rowWidths);
115
+ }
116
+ export function microShortcutFooterText(pageText, width) {
117
+ const safeWidth = Math.max(1, width);
118
+ const globalKeys = ['←/→', '?', 'q'];
119
+ const pageKeys = footerTokens(pageText)
120
+ .map(shortcutKey)
121
+ .filter((value) => Boolean(value) && !globalKeys.includes(value));
122
+ const uniquePageKeys = [...new Set(pageKeys)];
123
+ while (uniquePageKeys.length > 0 && displayWidth([...uniquePageKeys, ...globalKeys].join(' ')) > safeWidth) {
124
+ uniquePageKeys.pop();
125
+ }
126
+ const keys = [...uniquePageKeys, ...globalKeys];
127
+ while (keys.length > 1 && displayWidth(keys.join(' ')) > safeWidth)
128
+ keys.shift();
129
+ return keys.join(' ');
130
+ }
131
+ function footerTokens(text) {
132
+ return text.split(' · ').map(value => value.trim()).filter(Boolean);
133
+ }
134
+ function compactLegendToken(token) {
135
+ return token
136
+ .replace('↑/↓ or j/k/p move', '↑/↓/j/k/p move')
137
+ .replace('↑/↓ or n/p move', '↑/↓/n/p move')
138
+ .replace('Enter choose/create/edit', 'Enter select')
139
+ .replace('Enter open/control', 'Enter control')
140
+ .replace('F7/F9 or ,/. station', 'F7/F9 ,/. station')
141
+ .replace('x twice delete', 'x×2 delete')
142
+ .replace('t station test', 't test');
143
+ }
144
+ function compactGlobalToken(token) {
145
+ return token.replace('←/→ tabs', '←/→').replace('? help', '?').replace('q quit', 'q');
146
+ }
147
+ function shortcutKey(token) {
148
+ if (/^(?:ALARM PLAYING|mpv enables controls)/i.test(token))
149
+ return null;
150
+ const match = token.match(/^(↑\/↓)(?: or ([^ ]+))?|^([^ ]+)/);
151
+ if (!match)
152
+ return null;
153
+ return match[1] ? `${match[1]}${match[2] ? `/${match[2]}` : ''}` : match[3] ?? null;
154
+ }
155
+ function fitLegendRows(pageTokens, globalTokens, rowWidths) {
156
+ for (let retained = pageTokens.length; retained >= 0; retained -= 1) {
157
+ const rows = bestRowSplit([...pageTokens.slice(0, retained), ...globalTokens], rowWidths);
158
+ if (rows)
159
+ return { rows, retainedPageTokens: retained };
160
+ }
161
+ return null;
162
+ }
163
+ function bestRowSplit(tokens, rowWidths) {
164
+ const rowCount = rowWidths.length;
165
+ if (tokens.length === 0)
166
+ return Array.from({ length: rowCount }, () => ' ');
167
+ if (tokens.some(token => displayWidth(token) > Math.max(...rowWidths)))
168
+ return null;
169
+ if (rowCount === 1) {
170
+ const row = tokens.join(' · ');
171
+ return displayWidth(row) <= rowWidths[0] ? [row] : null;
172
+ }
173
+ const candidates = [];
174
+ const visit = (start, rows) => { if (rows.length === rowCount - 1) {
175
+ const final = tokens.slice(start).join(' · ');
176
+ if (!final || displayWidth(final) > rowWidths[rows.length])
177
+ return;
178
+ const candidate = [...rows, final];
179
+ const widths = candidate.map(displayWidth);
180
+ candidates.push({ rows: candidate, difference: Math.max(...widths) - Math.min(...widths) });
181
+ return;
182
+ } const remainingRows = rowCount - rows.length - 1; for (let end = start + 1; end <= tokens.length - remainingRows; end += 1) {
183
+ const row = tokens.slice(start, end).join(' · ');
184
+ if (displayWidth(row) > rowWidths[rows.length])
185
+ break;
186
+ visit(end, [...rows, row]);
187
+ } };
188
+ if (tokens.length < rowCount) {
189
+ const row = tokens.join(' · ');
190
+ return displayWidth(row) <= rowWidths[0] ? [row, ...Array.from({ length: rowCount - 1 }, () => ' ')] : null;
191
+ }
192
+ visit(0, []);
193
+ return candidates.sort((a, b) => a.difference - b.difference)[0]?.rows ?? null;
194
+ }
195
+ function packWholeTokens(tokens, rowWidths) {
196
+ const rows = Array.from({ length: rowWidths.length }, () => '');
197
+ for (const token of tokens) {
198
+ const target = rows.findIndex((row, index) => displayWidth(row ? `${row} · ${token}` : token) <= rowWidths[index]);
199
+ if (target < 0)
200
+ continue;
201
+ rows[target] = rows[target] ? `${rows[target]} · ${token}` : token;
202
+ }
203
+ return rows.map(row => row || ' ');
204
+ }
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useRef, useState } from 'react';
3
+ import { shouldAnimateReceiver, shouldResetReceiverPulse } from './app-state.js';
4
+ const LIVE_RECEIVER_PULSE_MS = 80;
5
+ const SKYLINE_RECEIVER_PULSE_MS = 120;
6
+ const ReceiverPulseContext = createContext(0);
7
+ /**
8
+ * Own receiver animation below the application root so an animation frame only
9
+ * invalidates the visualizer that consumes the pulse. Pulse values are derived
10
+ * from elapsed time: a busy terminal may skip obsolete frames, but it never
11
+ * slows the animation or queues a backlog of root renders.
12
+ */
13
+ export function ReceiverAnimationProvider({ children, screen, playback, receiverStyle, reduceMotion }) {
14
+ const [pulse, setPulse] = useState(0);
15
+ const pulseRef = useRef(0);
16
+ const snapshotRef = useRef(null);
17
+ pulseRef.current = pulse;
18
+ const previousSnapshot = snapshotRef.current;
19
+ const currentSnapshot = {
20
+ screen,
21
+ receiverStyle,
22
+ playbackState: playback.state,
23
+ playbackReady: playback.ready
24
+ };
25
+ const enteredNowPlaying = screen === 'now-playing' && previousSnapshot?.screen !== 'now-playing';
26
+ const changedStyle = previousSnapshot !== null && previousSnapshot.receiverStyle !== receiverStyle;
27
+ const resetPending = enteredNowPlaying ||
28
+ changedStyle ||
29
+ shouldResetReceiverPulse(previousSnapshot, currentSnapshot);
30
+ useEffect(() => {
31
+ if (resetPending) {
32
+ pulseRef.current = 0;
33
+ setPulse(0);
34
+ }
35
+ snapshotRef.current = currentSnapshot;
36
+ }, [playback.ready, playback.state, receiverStyle, resetPending, screen]);
37
+ useEffect(() => {
38
+ if (!shouldAnimateReceiver(screen, playback) ||
39
+ reduceMotion ||
40
+ process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
41
+ process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
42
+ return;
43
+ }
44
+ const intervalMs = receiverStyle === 'skyline' ? SKYLINE_RECEIVER_PULSE_MS : LIVE_RECEIVER_PULSE_MS;
45
+ const startedAt = performance.now();
46
+ const startingPulse = pulseRef.current;
47
+ let timer = null;
48
+ const schedule = () => {
49
+ const elapsed = performance.now() - startedAt;
50
+ const completedFrames = Math.floor(elapsed / intervalMs);
51
+ const delay = Math.max(1, (completedFrames + 1) * intervalMs - elapsed);
52
+ timer = setTimeout(() => {
53
+ const nextFrame = Math.max(1, Math.floor((performance.now() - startedAt) / intervalMs));
54
+ setPulse(startingPulse + nextFrame);
55
+ schedule();
56
+ }, delay);
57
+ };
58
+ schedule();
59
+ return () => {
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ };
63
+ }, [playback.ready, playback.state, receiverStyle, reduceMotion, screen]);
64
+ return _jsx(ReceiverPulseContext.Provider, { value: resetPending ? 0 : pulse, children: children });
65
+ }
66
+ export function useReceiverPulse() {
67
+ return useContext(ReceiverPulseContext);
68
+ }
@@ -6,11 +6,14 @@ export const homeItems = [
6
6
  { screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
7
7
  { screen: 'nearby', label: 'Nearby', detail: 'Approximate IP location for local stations' },
8
8
  { screen: 'stats', label: 'Stats', detail: 'Listening graph, stations, streaks, hours' },
9
+ { screen: 'alarms', label: 'Alarms (beta)', detail: 'Wake to radio and schedule station playback' },
9
10
  { screen: 'settings', label: 'Settings', detail: 'Audio output, colors, providers' }
10
11
  ];
11
12
  export const settingsGroups = [
12
13
  {
13
- label: 'Playback',
14
+ id: 'playback',
15
+ label: 'Playback & audio',
16
+ description: 'Output, volume, AirPlay, and startup behavior',
14
17
  items: [
15
18
  'Audio output',
16
19
  'AirPlay receiver',
@@ -22,7 +25,9 @@ export const settingsGroups = [
22
25
  ]
23
26
  },
24
27
  {
25
- label: 'Display',
28
+ id: 'appearance',
29
+ label: 'Appearance',
30
+ description: 'Colors, receiver style, motion, and terminal display',
26
31
  items: [
27
32
  'Cycle display color',
28
33
  'Cycle receiver style',
@@ -33,22 +38,40 @@ export const settingsGroups = [
33
38
  ]
34
39
  },
35
40
  {
36
- label: 'Discovery & privacy',
41
+ id: 'discovery',
42
+ label: 'Discovery & providers',
43
+ description: 'Location, station directories, privacy, and provider health',
37
44
  items: [
38
45
  'Toggle nearby location lookup',
39
46
  'Toggle Radio Garden experimental adapter',
40
- 'Share favorite votes with Radio Browser'
47
+ 'Share favorite votes with Radio Browser',
48
+ 'Refresh provider health'
41
49
  ]
42
50
  },
43
51
  {
44
- label: 'Data',
52
+ id: 'data',
53
+ label: 'Data & library',
54
+ description: 'Back up or restore preferences and saved stations',
45
55
  items: [
46
56
  'Export preferences and library',
47
57
  'Import preferences and library'
48
58
  ]
49
59
  },
50
60
  {
61
+ id: 'agent',
62
+ label: 'Agent control & MCP',
63
+ description: 'Local agent access and MCP playback behavior',
64
+ items: [
65
+ 'Allow local agent control',
66
+ 'Install or repair MCP integrations',
67
+ 'Open TUI for agent playback',
68
+ 'Show Now Playing for agent playback'
69
+ ]
70
+ },
71
+ {
72
+ id: 'media-keys',
51
73
  label: 'Media keys',
74
+ description: 'Teach RadioCLI your keyboard playback controls',
52
75
  items: [
53
76
  'Learn previous media key',
54
77
  'Learn play/pause media key',
@@ -57,14 +80,23 @@ export const settingsGroups = [
57
80
  ]
58
81
  },
59
82
  {
60
- label: 'Maintenance',
83
+ id: 'updates',
84
+ label: 'Updates',
85
+ description: 'Automatic launch checks, version status, and installation',
61
86
  items: [
62
- 'Refresh provider health',
87
+ 'Automatically check for updates',
63
88
  'Check for updates'
64
89
  ]
65
90
  }
66
91
  ];
67
92
  export const settingsItems = settingsGroups.flatMap(group => group.items);
68
- export function settingsSectionFor(item) {
69
- return settingsGroups.find(group => group.items.some(candidate => candidate === item))?.label ?? 'Preferences';
93
+ export const settingsRootItems = settingsGroups.map(group => group.label);
94
+ export function settingsGroup(page) {
95
+ return settingsGroups.find(group => group.id === page);
96
+ }
97
+ export function settingsItemsForPage(page) {
98
+ return page === 'root' ? settingsRootItems : settingsGroup(page)?.items ?? [];
99
+ }
100
+ export function settingsPageForRootItem(item) {
101
+ return settingsGroups.find(group => group.label === item)?.id;
70
102
  }
@@ -23,6 +23,10 @@ export const screenMetadata = {
23
23
  const secondaryTitles = {
24
24
  stations: 'Stations',
25
25
  map: 'World map',
26
+ alarms: 'Alarms',
27
+ 'alarm-editor': 'Alarm editor',
28
+ 'alarm-picker': 'Choose station',
29
+ 'alarm-ringing': 'Alarm ringing',
26
30
  'airplay-settings': 'AirPlay',
27
31
  'airplay-code': 'AirPlay code',
28
32
  help: 'Help'
@@ -0,0 +1,202 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { draftNextOccurrence } from '../alarm-editor.js';
4
+ import { nextOccurrenceForAlarm } from '../../alarms/schedule.js';
5
+ import { stationKey } from '../../storage/store.js';
6
+ import { visibleWindow } from '../list-window.js';
7
+ import { ScreenHeader } from '../components/ScreenHeader.js';
8
+ import { textMuted, themeAccent } from '../theme.js';
9
+ import { truncate } from '../format.js';
10
+ import { useDisplay } from '../display-context.js';
11
+ import { toAsciiSafe } from '../ascii.js';
12
+ import { AdaptiveMarquee } from '../components/AdaptiveMarquee.js';
13
+ export function AlarmsScreen({ alarms, selected, runtime, verification, deletingId, busyAlarmIds = new Set(), theme, width, height, mode: requestedMode }) {
14
+ const accent = themeAccent(theme);
15
+ const { ascii, reduceMotion } = useDisplay();
16
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
17
+ const mode = requestedMode ?? (width < 34 ? 'micro' : width < 68 ? 'compact' : 'full');
18
+ if (verification)
19
+ return _jsx(AlarmVerificationScreen, { report: verification, theme: theme, width: width, height: height });
20
+ 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];
22
+ const noticeRows = height >= 11 ? 2 : 1;
23
+ const rows = Math.max(1, height - 5 - noticeRows);
24
+ const items = [{ kind: 'create' }, ...alarms.map(alarm => ({ kind: 'alarm', alarm })), { kind: 'verify' }];
25
+ const window = visibleWindow(items, Math.min(selected, items.length - 1), rows);
26
+ return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Alarms", subtitle: status, theme: theme, width: width }), _jsx(Text, { color: next ? accent : textMuted, children: a(truncate(next ? nextAlarmSummary(next.alarm, next.occurrence, mode) : mode === 'full' ? 'No enabled future alarm.' : 'No upcoming alarms.', width)) }), _jsx(Box, { marginTop: 1, flexDirection: "column", height: rows, overflow: "hidden", children: window.items.map((item, offset) => {
27
+ const index = window.start + offset;
28
+ const active = index === selected;
29
+ if (item.kind === 'create')
30
+ return _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(`${active ? '› ' : ' '}Create alarm`) }, "create-alarm");
31
+ if (item.kind === 'verify')
32
+ return _jsxs(Box, { children: [_jsx(Text, { color: active ? accent : undefined, bold: active, children: a(active ? '› ' : ' ') }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: a(verifyLabel(mode)), width: Math.max(0, width - 2), active: active, reduceMotion: reduceMotion }) })] }, "verify-alarm");
33
+ const alarm = item.alarm;
34
+ const degraded = runtime?.degradedAlarmIds.has(alarm.id);
35
+ const cue = degraded ? ' · needs repair' : '';
36
+ const deleting = busyAlarmIds.has(alarm.id) ? ' · deleting…' : deletingId === alarm.id ? ' · press x again to delete' : '';
37
+ const rowText = `${alarmRowSummary(alarm, mode)}${cue}${deleting}`;
38
+ return _jsxs(Box, { children: [_jsx(Text, { color: active ? accent : alarm.enabled ? undefined : textMuted, bold: active, children: a(`${active ? '› ' : ' '}${alarm.enabled ? '●' : '○'} `) }), _jsx(Text, { color: active ? accent : alarm.enabled ? undefined : textMuted, bold: active, children: _jsx(AdaptiveMarquee, { text: a(rowText), width: Math.max(0, width - 4), active: active, reduceMotion: reduceMotion }) })] }, alarm.id);
39
+ }) }), height >= 11 ? _jsx(Text, { color: textMuted, children: a(staticAlarmMessage(backgroundPromise(runtime, mode), width)) }) : null, _jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(staticAlarmMessage(betaNotice(mode), width)) })] });
40
+ }
41
+ function AlarmVerificationScreen({ report, theme, width, height }) {
42
+ const accent = themeAccent(theme);
43
+ const { ascii } = useDisplay();
44
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
45
+ const heading = report.state === 'running' ? 'Verification in progress' : report.state === 'passed' ? 'Alarm setup verified' : report.state === 'warning' ? 'Alarm setup verified with limitations' : 'Alarm setup has blockers';
46
+ const subtitle = report.alarmLabel ? `Rehearsing “${report.alarmLabel}” on this machine` : 'Infrastructure check · create an alarm to include its real station';
47
+ const rows = Math.max(1, height - 5);
48
+ const window = visibleWindow(report.steps, Math.max(0, report.steps.findIndex(step => step.state === 'running')), Math.max(1, Math.floor(rows / 2)));
49
+ return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: heading, subtitle: subtitle, theme: theme, width: width }), _jsx(Text, { color: report.state === 'passed' ? accent : report.state === 'running' ? accent : undefined, bold: true, children: a(report.state === 'running' ? 'A temporary terminal may open and a 3-second radio sample will play.' : report.state === 'passed' ? 'PASS · The complete alarm rehearsal succeeded.' : report.state === 'warning' ? 'READY WITH LIMITATIONS · Review warnings before relying on wake behavior.' : 'NOT READY · Fix failed critical steps and run Verify again.') }), _jsx(Box, { marginTop: 1, flexDirection: "column", height: rows, overflow: "hidden", children: window.items.map(step => _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: step.state === 'passed' ? accent : step.state === 'running' ? accent : step.state === 'failed' ? 'red' : step.state === 'warning' ? 'yellow' : textMuted, bold: step.state !== 'pending', children: a(`${verificationMark(step.state)} ${step.label}`) }), _jsx(Text, { color: textMuted, children: a(` ${truncate(step.detail, Math.max(1, width - 3))}`) })] }, step.id)) }), _jsx(Text, { color: textMuted, children: a(report.state === 'running' ? 'Please keep RadioCLI open until cleanup completes.' : 'Press Enter or b to return to Alarms. Run Verify again whenever the OS, terminal, or audio setup changes.') })] });
50
+ }
51
+ export function AlarmEditorScreen({ draft, field, editing, control = null, timeSegment = 'hour', weekdayIndex = 0, error, saving = false, theme, width, height }) {
52
+ const accent = themeAccent(theme);
53
+ const { ascii } = useDisplay();
54
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
55
+ const rows = editorRows(draft);
56
+ const selectableIndex = rows.findIndex(row => row.field === field);
57
+ const viewportHeight = Math.max(1, height - 5);
58
+ const window = visibleWindow(rows, Math.max(0, selectableIndex), viewportHeight);
59
+ return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: draft.id ? 'Edit alarm' : 'New alarm', subtitle: saving ? 'Saving locally and synchronizing the native scheduler…' : error ?? 'Local-first · native OS job installed on save · terminal may close afterward', theme: theme, width: width }), _jsx(Box, { marginTop: 1, flexDirection: "column", height: viewportHeight, overflow: "hidden", children: control === 'weekdays' ? _jsx(WeekdayControl, { draft: draft, selected: weekdayIndex, accent: accent, ascii: ascii, height: viewportHeight }) : window.items.map(row => row.section
60
+ ? _jsx(Text, { color: accent, bold: true, children: a(row.section) }, row.section)
61
+ : row.field === 'time' && row.field === field && control === 'time'
62
+ ? _jsx(EditorColumns, { width: width, help: a(editorHelp('time') ?? ''), ascii: ascii, children: _jsx(TimeControlRow, { value: draft.time, segment: timeSegment, accent: accent, ascii: ascii }) }, "time")
63
+ : _jsx(EditorFieldRow, { row: row, selected: row.field === field, editing: editing, control: row.field === field ? control : null, draft: draft, accent: accent, ascii: ascii, width: width }, row.field)) })] });
64
+ }
65
+ export function AlarmPickerScreen({ choices, selected, fallback, theme, width, height }) {
66
+ const window = visibleWindow(choices, selected, Math.max(1, height - 5));
67
+ const accent = themeAccent(theme);
68
+ const { ascii } = useDisplay();
69
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
70
+ return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: fallback ? 'Fallback station' : 'Primary station', subtitle: "Favorites, imports, recents, current station, and alarm stations", theme: theme, width: width }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map((choice, offset) => {
71
+ const index = window.start + offset;
72
+ return _jsx(Text, { color: index === selected ? accent : undefined, bold: index === selected, children: a(`${index === selected ? '› ' : ' '}${truncate(`${choice.station?.name ?? 'None'} · ${choice.source}`, Math.max(1, width - 2))}`) }, choice.key);
73
+ }) })] });
74
+ }
75
+ export function AlarmRingingScreen({ sessions, alarms = [], selected, snoozeMinutes, theme, width, height }) {
76
+ const accent = themeAccent(theme);
77
+ const session = sessions[selected];
78
+ const label = session ? alarms.find(alarm => alarm.id === session.status.alarmId)?.label : undefined;
79
+ const starting = session?.status.state === 'starting';
80
+ const { ascii } = useDisplay();
81
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
82
+ const roomy = width >= 42 && height >= 12;
83
+ const clockText = session ? formatClock(new Date(session.status.scheduledAt)) : '';
84
+ const displayClock = roomy && !ascii ? fullwidthClock(clockText) : clockText;
85
+ return _jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Alarm ringing", subtitle: sessions.length > 1 ? `${selected + 1} of ${sessions.length} active alarms` : starting ? 'Wake radio · preparing station' : 'Wake radio · playing now', theme: theme, width: width }), session ? _jsxs(Box, { flexDirection: "column", alignItems: "center", width: width, children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(starting ? ' ALARM STARTING ' : ' ALARM ACTIVE ') }), _jsx(Box, { marginTop: roomy ? 1 : 0, children: _jsx(Text, { backgroundColor: roomy ? accent : undefined, color: roomy ? 'black' : accent, bold: true, children: a(roomy ? ` ${displayClock} ` : displayClock) }) }), _jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: roomy ? 1 : 0, width: width, children: [_jsx(Text, { color: accent, bold: true, children: a(truncate(label ? `${session.status.stationName} · ${label}` : session.status.stationName, width)) }), height >= 12 ? _jsx(Text, { color: textMuted, children: a(truncate(`${starting ? 'Preparing' : 'Started'} ${formatElapsed(session.status.startedAt)} ago · scheduled ${formatWhen(new Date(session.status.scheduledAt))}`, width)) }) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: width >= 58 ? 'row' : 'column', alignItems: "center", children: [_jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(starting ? ' ENTER WAIT FOR STATION ' : ' ENTER KEEP PLAYING ') }), width >= 58 ? _jsx(Text, { children: " " }) : null, _jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: a(` SPACE SNOOZE ${snoozeMinutes} MIN `) })] }), height >= 14 ? _jsx(Text, { color: textMuted, children: a(starting ? 'Space snoozes before playback · b return' : 'Enter opens Playing · Space stops the radio until snooze ends · b return') }) : null, sessions.length > 1 && height >= 15 ? _jsx(Text, { color: textMuted, children: a(`↑/↓ choose active alarm · ${selected + 1}/${sessions.length}`) }) : null] }) : _jsx(Text, { color: textMuted, children: "The alarm session ended. Press b to return." })] });
86
+ }
87
+ function editorRows(draft) {
88
+ const next = draftNextOccurrence(draft);
89
+ return [
90
+ { section: 'BASICS' },
91
+ { field: 'label', label: 'Label', value: draft.label }, { field: 'enabled', label: 'Enabled', value: draft.enabled ? 'Yes' : 'No' },
92
+ { field: 'station', label: 'Station', value: draft.station?.name ?? 'Choose…' }, { field: 'scheduleType', label: 'Schedule', value: draft.scheduleType === 'once' ? 'One time' : 'Recurring' },
93
+ ...(draft.scheduleType === 'once' ? [{ field: 'date', label: 'Local date', value: draft.date }] : []),
94
+ { field: 'time', label: 'Time (24h)', value: draft.time },
95
+ ...(draft.scheduleType === 'recurring' ? [{ field: 'weekdays', label: 'Repeat', value: weekdayDraftLabel(draft.weekdays) }] : []),
96
+ { field: 'timezone', label: 'Timezone', value: draft.timezone },
97
+ { section: 'PLAYBACK' }, { field: 'volume', label: 'Volume', value: `${draft.volume}%` }, { field: 'fadeSeconds', label: 'Fade in', value: `${draft.fadeSeconds}s (mpv only)` },
98
+ { field: 'stopAfterMinutes', label: 'Stop after', value: `${draft.stopAfterMinutes}m` }, { field: 'fallbackStation', label: 'Fallback', value: draft.fallbackStation?.name ?? 'None' }, { field: 'output', label: 'Output', value: 'Local speakers' },
99
+ { section: 'RELIABILITY' }, { field: 'missedRunGraceMinutes', label: 'Run after wake within', value: `${draft.missedRunGraceMinutes}m` },
100
+ { field: 'wakeIfSupported', label: 'Request OS wake', value: draft.wakeIfSupported ? 'Yes' : 'No' }, { field: 'keepAwakeUntilAlarm', label: 'Alarm Guard', value: draft.keepAwakeUntilAlarm ? 'On' : 'Off' },
101
+ { field: 'preview', label: 'Test-tune station now (does not fire alarm)' }, { section: 'REVIEW' },
102
+ { field: 'save', label: 'Save alarm', value: next ? `next ${formatWhen(next)}` : 'fix fields to preview next run' }, { field: 'cancel', label: 'Cancel' }
103
+ ];
104
+ }
105
+ function editorHelp(field) {
106
+ const help = {
107
+ timezone: 'An explicit IANA timezone keeps civil schedules stable across travel and DST.',
108
+ fadeSeconds: 'Fade-in requires mpv; other playback backends start at the target volume.',
109
+ stopAfterMinutes: 'Playback stops automatically unless Keep playing is chosen while ringing.',
110
+ fallbackStation: 'The fallback is tried when the primary stream cannot start. Choose None to clear it.',
111
+ output: 'Scheduled alarms currently play through local speakers; AirPlay is not used unattended.',
112
+ missedRunGraceMinutes: 'After sleep or logout, run only when the alarm is still inside this grace window.',
113
+ wakeIfSupported: 'Asks supported systems to wake the computer for this alarm.',
114
+ keepAwakeUntilAlarm: 'Alarm Guard prevents idle sleep until this occurrence. It is machine-local and consumes power.',
115
+ time: 'Enter opens the time control; ←/→ chooses hours or minutes and ↑/↓ changes the value.',
116
+ weekdays: 'Enter opens the weekday checklist. Choose any combination of Monday through Sunday.',
117
+ volume: 'Sets player volume and raises/unmutes the local system output to this level while the alarm plays.',
118
+ station: 'Choose the primary stream from current, favorites, imports, recents, or existing alarms.'
119
+ };
120
+ return help[field];
121
+ }
122
+ function EditorFieldRow({ row, selected, editing, control, draft, accent, ascii, width }) {
123
+ const safe = (value) => ascii ? toAsciiSafe(value) : value;
124
+ const field = row.field;
125
+ const value = `${row.label}${row.value === undefined ? '' : `: ${editorValue(field, row.value, draft, control)}`}${selected && editing ? ' █' : ''}`;
126
+ const leftWidth = editorLeftWidth(width);
127
+ const content = safe(`${selected ? '› ' : ' '}${truncate(value, Math.max(1, leftWidth - 2))}`);
128
+ const action = field === 'save' || field === 'cancel';
129
+ const actionText = safe(truncate(`${selected ? '›' : ' '} ${row.label}${row.value ? ` · ${row.value}` : ''} `, leftWidth).padEnd(leftWidth));
130
+ const left = action && selected ? _jsx(Text, { backgroundColor: accent, color: "black", bold: true, children: actionText }) : _jsx(Text, { color: selected ? accent : undefined, bold: selected, children: content });
131
+ return _jsx(EditorColumns, { width: width, help: safe(editorHelp(field) ?? ''), ascii: ascii, children: left });
132
+ }
133
+ function EditorColumns({ width, help, ascii, children }) { const leftWidth = editorLeftWidth(width); const showHelp = Boolean(help) && width >= 72; const description = truncate(help ?? '', Math.max(1, width - leftWidth - 2)); return _jsxs(Box, { width: width, children: [_jsx(Box, { width: showHelp ? leftWidth : width, children: children }), showHelp ? _jsx(Box, { marginLeft: 2, width: Math.max(1, width - leftWidth - 2), children: _jsx(Text, { color: textMuted, children: ascii ? toAsciiSafe(description) : description }) }) : null] }); }
134
+ function editorLeftWidth(width) { return Math.min(width, 54, Math.max(28, Math.floor(width * 0.46))); }
135
+ function editorValue(field, value, draft, control) {
136
+ if (field === 'time') {
137
+ const [hour = '00', minute = '00'] = draft.time.split(':');
138
+ return `${hour} : ${minute}`;
139
+ }
140
+ if (field === 'volume') {
141
+ const level = Math.max(0, Math.min(10, Math.round(Number(draft.volume) / 10)));
142
+ return `${value} [${'█'.repeat(level)}${'·'.repeat(10 - level)}]${control === 'number' ? ' ←/→' : ''}`;
143
+ }
144
+ if (control === 'number' && ['fadeSeconds', 'stopAfterMinutes', 'missedRunGraceMinutes'].includes(field ?? ''))
145
+ return `${value} ←/→`;
146
+ return value;
147
+ }
148
+ function TimeControlRow({ value, segment, accent, ascii }) {
149
+ const [hour = '00', minute = '00'] = value.split(':');
150
+ const pointer = ascii ? '> ' : '› ';
151
+ const digit = (text, active) => _jsx(Text, { backgroundColor: active ? accent : undefined, color: active ? 'black' : accent, bold: true, children: ` ${text} ` });
152
+ return _jsxs(Text, { color: accent, bold: true, children: [pointer, "Time: ", digit(hour, segment === 'hour'), " : ", digit(minute, segment === 'minute')] });
153
+ }
154
+ function WeekdayControl({ draft, selected, accent, ascii, height }) {
155
+ const chosen = new Set(draft.weekdays.split(',').map(Number));
156
+ const names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
157
+ const decorated = height >= 3;
158
+ const window = visibleWindow(names, selected, decorated ? Math.max(1, height - 2) : height);
159
+ const safe = (value) => ascii ? toAsciiSafe(value) : value;
160
+ return _jsxs(Box, { flexDirection: "column", children: [decorated ? _jsx(Text, { color: accent, bold: true, children: "WEEKDAYS" }) : null, window.items.map((name, offset) => {
161
+ const index = window.start + offset;
162
+ return _jsx(Text, { color: index === selected ? accent : undefined, bold: index === selected, children: safe(`${index === selected ? '› ' : ' '}${chosen.has(index + 1) ? '●' : '○'} ${name}`) }, name);
163
+ }), decorated ? _jsx(Text, { color: textMuted, children: safe('Select one or more days. At least one day must remain checked.') }) : null] });
164
+ }
165
+ export function alarmPickerChoices(alarms, favorites, imported, recent, playing, fallback) {
166
+ const result = fallback ? [{ key: 'none', source: 'No fallback' }] : [];
167
+ const seen = new Set();
168
+ const add = (station, source) => { if (!station)
169
+ return; const key = stationKey(station); if (seen.has(key))
170
+ return; seen.add(key); result.push({ station, source, key }); };
171
+ add(playing, 'Now playing');
172
+ favorites.forEach(value => add(value, 'Favorite'));
173
+ imported.forEach(value => add(value, 'Imported'));
174
+ recent.forEach(value => add(value.station, 'Recent'));
175
+ alarms.forEach(value => add(value.station, 'Alarm'));
176
+ return result;
177
+ }
178
+ function weekdayLabel(days) { if (days.join(',') === '1,2,3,4,5')
179
+ return 'weekdays'; if (days.join(',') === '6,7')
180
+ return 'weekends'; if (days.length === 7)
181
+ return 'daily'; return days.map(day => ['', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'][day]).join(' '); }
182
+ function weekdayDraftLabel(value) { return weekdayLabel(value.split(',').map(Number)); }
183
+ function shortZone(value) { return value.replace('America/', '').replaceAll('_', ' '); }
184
+ function formatWhen(date) { return `${new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(date)} (your time)`; }
185
+ function formatCompactWhen(date, mode) { return new Intl.DateTimeFormat(undefined, mode === 'micro' ? { weekday: 'short', hour: 'numeric', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(date); }
186
+ function formatElapsed(startedAt) { const seconds = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; }
187
+ function formatClock(date) { return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date); }
188
+ function fullwidthClock(value) { return [...value].map(character => character === ' ' ? ' ' : character >= '0' && character <= '9' ? String.fromCodePoint(character.codePointAt(0) + 0xfee0) : character === ':' ? ':' : character >= 'A' && character <= 'Z' ? String.fromCodePoint(character.codePointAt(0) + 0xfee0) : character).join(''); }
189
+ function verificationMark(state) { return state === 'passed' ? '●' : state === 'warning' ? '!' : state === 'failed' ? '×' : state === 'running' ? '›' : '○'; }
190
+ function schedulerStatus(runtime, mode) { if (!runtime)
191
+ return mode === 'micro' ? 'Checking scheduler…' : 'Scheduler: checking…'; if (!runtime.capabilities.supported)
192
+ return mode === 'full' ? `Scheduler: Unsupported · ${runtime.capabilities.message}` : mode === 'compact' ? 'Scheduler: Unavailable' : 'Scheduler unavailable'; if (runtime.degradedAlarmIds.size)
193
+ return mode === 'full' ? `Scheduler: Degraded · ${runtime.message}` : mode === 'compact' ? 'Scheduler: Repair needed' : 'Repair scheduler'; return mode === 'micro' ? 'Scheduler ready' : 'Scheduler: Ready'; }
194
+ function nextAlarmSummary(alarm, occurrence, mode) { const when = mode === 'full' ? formatWhen(occurrence) : formatCompactWhen(occurrence, mode); return mode === 'micro' ? `Next · ${when}` : `${mode === 'full' ? 'Next wake' : 'Next'} · ${when} · ${alarm.station.name}`; }
195
+ function alarmRowSummary(alarm, mode) { const station = alarm.label.trim().toLocaleLowerCase() === alarm.station.name.trim().toLocaleLowerCase() ? '' : ` · ${alarm.station.name}`; const schedule = alarm.schedule.type === 'once' ? (mode === 'full' ? `once ${formatWhen(new Date(alarm.schedule.at))}` : `once ${formatCompactWhen(new Date(alarm.schedule.at), mode)}`) : `${alarm.schedule.time} ${weekdayLabel(alarm.schedule.weekdays)}${mode === 'full' ? ` ${shortZone(alarm.schedule.timezone)}` : ''}`; return `${alarm.label} · ${schedule}${station}`; }
196
+ function verifyLabel(mode) { return mode === 'full' ? 'Verify alarm setup · full scheduler, terminal, controls, power, volume, and audio rehearsal' : mode === 'compact' ? 'Verify alarm setup · scheduler, terminal, power, and audio' : 'Verify alarm setup'; }
197
+ function backgroundPromise(runtime, mode) { if (!runtime)
198
+ return mode === 'full' ? ['Checking whether the terminal may close after save…'] : ['Checking scheduler…']; if (!runtime.capabilities.supported)
199
+ return mode === 'full' ? [`Background scheduling unavailable: ${runtime.capabilities.message}`] : ['Background scheduling unavailable', 'Scheduler unavailable']; if (runtime.degradedAlarmIds.size)
200
+ return mode === 'full' ? ['Background scheduling needs repair; keep the alarm visible and press r.'] : ['Scheduling needs repair · press r', 'Repair needed · press r']; return mode === 'full' ? ['Native scheduling is ready; the terminal does not need to stay open after save.'] : ['Native scheduling ready · safe to close after save', 'Scheduler ready · safe to close']; }
201
+ function betaNotice(mode) { return mode === 'full' ? [' BETA · Experimental. Use a secondary device for critical wake-ups. '] : mode === 'compact' ? ['BETA · Keep a backup alarm for critical wakes', 'BETA · Keep a backup alarm'] : ['BETA · Keep a backup alarm', 'BETA · Keep backup', 'BETA']; }
202
+ function staticAlarmMessage(variants, width) { return variants.find(value => value.length <= width) ?? truncate(variants.at(-1) ?? '', width); }
@@ -4,16 +4,19 @@ import { Menu, Pointer } from '../components/Menu.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { visibleWindow } from '../list-window.js';
7
- import { displayWidth, truncate } from '../format.js';
8
- export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
7
+ import { displayWidth } from '../format.js';
8
+ import { AdaptiveMarquee } from '../components/AdaptiveMarquee.js';
9
+ export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width, reduceMotion = false }) {
9
10
  const window = visibleWindow(countries, selected, pageSize);
10
11
  const rowWidth = Math.max(24, width - 2);
11
12
  const countWidth = Math.max(12, Math.min(22, Math.max(...countries.map(country => `${country.stationCount.toLocaleString()} stations`.length), 12)));
12
- const visibleCountryWidth = Math.max(8, ...window.items.map(country => displayWidth(`${country.name} (${country.code})`)));
13
- const countryWidth = Math.min(42, visibleCountryWidth, Math.max(8, rowWidth - countWidth - 4));
13
+ // Keep the metadata column fixed while the viewport scrolls. Forty-two cells
14
+ // accommodates long directory names such as Lao People's Democratic Republic
15
+ // plus its country code, while narrow terminals still receive a bounded row.
16
+ const countryWidth = Math.min(42, Math.max(8, rowWidth - countWidth - 4));
14
17
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Countries", subtitle: editingFilter ? 'Filtering countries — type to narrow the list' : 'Browse the worldwide country directory', width: width, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Showing ", countries.length ? window.start + 1 : 0, "-", window.end, " of ", countries.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => {
15
18
  const code = ` (${country.code})`;
16
19
  const nameWidth = Math.max(3, countryWidth - displayWidth(code));
17
- return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: countryWidth, children: [_jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: truncate(country.name, nameWidth) }), _jsx(Text, { color: textMuted, children: code })] }), _jsx(Box, { width: 2 }), _jsx(Box, { width: countWidth, children: _jsxs(Text, { color: textMuted, children: [country.stationCount.toLocaleString(), " stations"] }) })] }));
20
+ return (_jsxs(Box, { height: 1, width: rowWidth, children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: countryWidth, children: [_jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: country.name, width: nameWidth, active: active, reduceMotion: reduceMotion }) }), _jsx(Text, { color: textMuted, children: code })] }), _jsx(Box, { width: 2 }), _jsx(Box, { width: countWidth, children: _jsxs(Text, { color: textMuted, children: [country.stationCount.toLocaleString(), " stations"] }) })] }));
18
21
  } })] })) : null] }));
19
22
  }
@@ -8,12 +8,17 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { exploreMapLand, mapMarker, panelBorder, textMuted, themeAccent } from '../theme.js';
9
9
  import { panelBorderStyle, useDisplay } from '../display-context.js';
10
10
  import { toAsciiSafe } from '../ascii.js';
11
- export function ExploreScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
11
+ import { truncate } from '../format.js';
12
+ export function ExploreScreen({ title, subtitle, stations, selected, loading, error, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
12
13
  const { panel: panelBackground, ascii } = useDisplay();
13
- const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
14
+ const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, mapOffsetX, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
14
15
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
15
16
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
16
- 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: 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 ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2), showCount: false, emptyTitle: loading ? 'Loading stations…' : 'No stations near this point.', emptyHint: "Move the map cursor to explore another area." })] })] })] }));
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
+ ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…')
19
+ : error
20
+ ? truncate(ascii ? toAsciiSafe(error) : error, Math.max(1, listPanelWidth - 2))
21
+ : ' ' }) }), (!loading || stations.length > 0) && (stations.length > 0 || !error) ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(1, listPanelWidth - 2), showCount: false, emptyTitle: "No stations near this point.", emptyHint: "Move the map cursor to explore another area." }) : null] })] })] }));
17
22
  }
18
23
  function CosmoMapLine({ row, theme }) {
19
24
  const { ascii } = useDisplay();
@@ -4,6 +4,8 @@ import { Logo } from '../components/Logo.js';
4
4
  import { Menu, Pointer } from '../components/Menu.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
+ import { displayWidth } from '../format.js';
7
8
  export function HomeScreen({ selected, theme, library }) {
8
- 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(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", 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"] }) })] }));
9
+ 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"] }) })] }));
9
11
  }