@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
@@ -0,0 +1,727 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { applyTextInput, clamp, isEditableInput } from './app-state.js';
3
+ import { adjustTime, alarmEditorFields, alarmInputFromDraft, alarmTextFields, cycleWeekdays, defaultAlarmDraft, draftFromAlarm, toggleWeekday, validateAlarmDraft } from './alarm-editor.js';
4
+ import { alarmPickerChoices } from './screens/AlarmsScreen.js';
5
+ export function useAlarmTui(params) {
6
+ const { service, store, library, setLibrary, screen, selected, setSelected, go, setMessage, playingStation, previewStation } = params;
7
+ const [draft, setDraft] = useState(null);
8
+ const [editingField, setEditingField] = useState(false);
9
+ const [editorControl, setEditorControl] = useState(null);
10
+ const editorControlRef = useRef(null);
11
+ const [timeSegment, setTimeSegment] = useState('hour');
12
+ const [weekdayIndex, setWeekdayIndex] = useState(0);
13
+ const timeSegmentRef = useRef('hour');
14
+ const weekdayIndexRef = useRef(0);
15
+ const [validationError, setValidationError] = useState(null);
16
+ const [saving, setSaving] = useState(false);
17
+ const savingRef = useRef(false);
18
+ const [pickerFallback, setPickerFallback] = useState(false);
19
+ const [runtime, setRuntime] = useState(null);
20
+ const [verification, setVerification] = useState(null);
21
+ const verificationRunningRef = useRef(false);
22
+ const [activeAlarms, setActiveAlarms] = useState([]);
23
+ const activeAlarmsRef = useRef([]);
24
+ const [activeSelected, setActiveSelected] = useState(0);
25
+ const snoozeMinutes = 10;
26
+ const [deletingId, setDeletingId] = useState();
27
+ const [busyAlarmIds, setBusyAlarmIds] = useState(() => new Set());
28
+ const deletingIdRef = useRef(undefined);
29
+ const busyAlarmIdsRef = useRef(new Set());
30
+ const alarmStateRef = useRef(library.alarms);
31
+ alarmStateRef.current = library.alarms;
32
+ const returnScreenRef = useRef('home');
33
+ const runtimePollingRef = useRef(false);
34
+ const runtimeDirtyRef = useRef(false);
35
+ const activePollingRef = useRef(false);
36
+ const activeCountRef = useRef(0);
37
+ const activeSelectedRef = useRef(0);
38
+ const activeSelectedKeyRef = useRef(undefined);
39
+ const pendingActiveActionsRef = useRef(new Set());
40
+ const acknowledgedActiveKeysRef = useRef(new Set());
41
+ const screenRef = useRef(screen);
42
+ screenRef.current = screen;
43
+ const alarmsRef = useRef(library.alarms);
44
+ alarmsRef.current = library.alarms;
45
+ const visibleFields = useMemo(() => alarmEditorFields.filter(field => {
46
+ if (field === 'date')
47
+ return draft?.scheduleType === 'once';
48
+ if (field === 'weekdays')
49
+ return draft?.scheduleType === 'recurring';
50
+ return true;
51
+ }), [draft?.scheduleType]);
52
+ const editorField = visibleFields[clamp(selected, visibleFields.length - 1)] ?? 'label';
53
+ const pickerChoices = useMemo(() => alarmPickerChoices(library.alarms, library.favorites, library.imported, library.recent, playingStation, pickerFallback), [library.alarms, library.favorites, library.imported, library.recent, pickerFallback, playingStation]);
54
+ const applyActiveSessions = useCallback((discovered) => {
55
+ const discoveredKeys = new Set(discovered.map(session => session.key));
56
+ for (const key of acknowledgedActiveKeysRef.current)
57
+ if (!discoveredKeys.has(key))
58
+ acknowledgedActiveKeysRef.current.delete(key);
59
+ const sessions = discovered.filter(session => !acknowledgedActiveKeysRef.current.has(session.key));
60
+ const preferredKey = activeSelectedKeyRef.current;
61
+ const preferredIndex = preferredKey ? sessions.findIndex(session => session.key === preferredKey) : -1;
62
+ const nextIndex = preferredIndex >= 0 ? preferredIndex : clamp(activeSelectedRef.current, sessions.length - 1);
63
+ activeSelectedRef.current = nextIndex;
64
+ setActiveSelected(nextIndex);
65
+ activeSelectedKeyRef.current = sessions[nextIndex]?.key;
66
+ const liveKeys = new Set(sessions.map(session => session.key));
67
+ for (const key of pendingActiveActionsRef.current)
68
+ if (!liveKeys.has(key))
69
+ pendingActiveActionsRef.current.delete(key);
70
+ activeAlarmsRef.current = sessions;
71
+ setActiveAlarms(sessions);
72
+ if (sessions.length > 0 && activeCountRef.current === 0 && screenRef.current !== 'alarm-ringing') {
73
+ returnScreenRef.current = screenRef.current;
74
+ go('alarm-ringing', { resetSelection: true, clearMessage: false });
75
+ }
76
+ activeCountRef.current = sessions.length;
77
+ }, [go]);
78
+ const refreshActive = useCallback(async () => {
79
+ if (activePollingRef.current)
80
+ return;
81
+ activePollingRef.current = true;
82
+ try {
83
+ const sessions = await service.activeAlarms();
84
+ applyActiveSessions(sessions);
85
+ }
86
+ catch (error) {
87
+ setMessage(`Active alarm controls unavailable: ${messageOf(error)}`);
88
+ }
89
+ finally {
90
+ activePollingRef.current = false;
91
+ }
92
+ }, [applyActiveSessions, service, setMessage]);
93
+ const refreshRuntime = useCallback(async () => {
94
+ if (runtimePollingRef.current) {
95
+ runtimeDirtyRef.current = true;
96
+ return;
97
+ }
98
+ runtimePollingRef.current = true;
99
+ try {
100
+ do {
101
+ runtimeDirtyRef.current = false;
102
+ try {
103
+ setRuntime(await service.runtimeStatus(alarmsRef.current));
104
+ }
105
+ catch (error) {
106
+ setMessage(`Alarm scheduler status unavailable: ${messageOf(error)}`);
107
+ }
108
+ } while (runtimeDirtyRef.current);
109
+ }
110
+ finally {
111
+ runtimePollingRef.current = false;
112
+ }
113
+ }, [service, setMessage]);
114
+ useEffect(() => {
115
+ let canceled = false;
116
+ void (async () => {
117
+ try {
118
+ // Discover playback first. Reinstalling or removing the native job that
119
+ // launched this process can terminate the currently ringing runner.
120
+ const sessions = await service.activeAlarms();
121
+ if (canceled)
122
+ return;
123
+ applyActiveSessions(sessions);
124
+ const activeIds = new Set(sessions.map(session => session.status.alarmId));
125
+ const alarms = store.listAlarms().filter(alarm => !busyAlarmIdsRef.current.has(alarm.id) && !activeIds.has(alarm.id));
126
+ const results = await service.syncAll(alarms);
127
+ if (canceled)
128
+ return;
129
+ const failures = results.filter(result => result.error);
130
+ if (failures.length)
131
+ setMessage(`Alarm startup sync: ${failures.length} need repair. Open Alarms and press r.`);
132
+ void refreshRuntime();
133
+ }
134
+ catch (error) {
135
+ if (!canceled)
136
+ setMessage(`Alarm startup safety check failed; native jobs were left untouched: ${messageOf(error)}`);
137
+ }
138
+ })();
139
+ const activeTimer = setInterval(() => void refreshActive(), 1_500);
140
+ const runtimeTimer = setInterval(() => void refreshRuntime(), 10 * 60_000);
141
+ activeTimer.unref?.();
142
+ runtimeTimer.unref?.();
143
+ return () => { canceled = true; clearInterval(activeTimer); clearInterval(runtimeTimer); };
144
+ }, [applyActiveSessions, refreshActive, refreshRuntime, service, setMessage, store]);
145
+ useEffect(() => { if (screen === 'alarms') {
146
+ setSelected(alarmsRef.current.length ? 1 : 0);
147
+ void refreshRuntime();
148
+ } }, [refreshRuntime, screen, setSelected]);
149
+ const openForStation = useCallback((station) => {
150
+ setDraft(defaultAlarmDraft(station ?? playingStation ?? undefined));
151
+ setEditingField(false);
152
+ editorControlRef.current = null;
153
+ setEditorControl(null);
154
+ timeSegmentRef.current = 'hour';
155
+ setTimeSegment('hour');
156
+ weekdayIndexRef.current = 0;
157
+ setWeekdayIndex(0);
158
+ setValidationError(null);
159
+ setSelected(0);
160
+ go('alarm-editor', { resetSelection: true });
161
+ }, [go, playingStation, setSelected]);
162
+ const syncPersisted = useCallback(async (alarm, success) => {
163
+ if (activeAlarmsRef.current.some(session => session.status.alarmId === alarm.id)) {
164
+ setMessage(`${success} Current playback was left untouched; the headless runner will reconcile the saved schedule when it ends.`);
165
+ return;
166
+ }
167
+ let terminalWarning;
168
+ try {
169
+ await service.prepareTerminalAccess?.();
170
+ }
171
+ catch (error) {
172
+ terminalWarning = messageOf(error);
173
+ }
174
+ try {
175
+ await service.sync(alarm);
176
+ setMessage(terminalWarning ? `${success} Scheduled, but automatic terminal controls need attention: ${terminalWarning}` : success);
177
+ }
178
+ catch (error) {
179
+ setMessage(`${success} Saved locally, but scheduling is degraded: ${messageOf(error)} Press r to repair.`);
180
+ }
181
+ await refreshRuntime();
182
+ }, [refreshRuntime, service, setMessage]);
183
+ const saveDraft = useCallback(() => {
184
+ if (!draft || savingRef.current)
185
+ return;
186
+ const error = validateAlarmDraft(draft);
187
+ if (error) {
188
+ setValidationError(error);
189
+ setMessage(error);
190
+ return;
191
+ }
192
+ savingRef.current = true;
193
+ setSaving(true);
194
+ try {
195
+ const input = alarmInputFromDraft(draft);
196
+ const alarm = draft.id ? store.updateAlarm(draft.id, input) : store.addAlarm(input);
197
+ setLibrary(store.snapshot());
198
+ setValidationError(null);
199
+ go('alarms', { resetSelection: true, clearMessage: false });
200
+ // Keep the guard set through any buffered input from this render, then let a
201
+ // newly opened editor save independently while native reconciliation continues.
202
+ queueMicrotask(() => { savingRef.current = false; setSaving(false); });
203
+ void syncPersisted(alarm, `${draft.id ? 'Alarm updated.' : 'Alarm created.'}`);
204
+ return;
205
+ }
206
+ catch (error) {
207
+ const text = messageOf(error);
208
+ setValidationError(text);
209
+ setMessage(text);
210
+ }
211
+ savingRef.current = false;
212
+ setSaving(false);
213
+ }, [draft, go, setLibrary, setMessage, store, syncPersisted]);
214
+ const editSelected = useCallback(() => {
215
+ const alarm = alarmStateRef.current[selected - 1];
216
+ if (!alarm)
217
+ return;
218
+ setDraft(draftFromAlarm(alarm));
219
+ setEditingField(false);
220
+ editorControlRef.current = null;
221
+ setEditorControl(null);
222
+ timeSegmentRef.current = 'hour';
223
+ setTimeSegment('hour');
224
+ weekdayIndexRef.current = 0;
225
+ setWeekdayIndex(0);
226
+ setValidationError(null);
227
+ setSelected(0);
228
+ go('alarm-editor', { resetSelection: true });
229
+ }, [go, library.alarms, selected, setSelected]);
230
+ const runVerification = useCallback(() => {
231
+ if (verificationRunningRef.current)
232
+ return;
233
+ if (!service.verifySetup) {
234
+ setMessage('Alarm setup verification is unavailable in this build.');
235
+ return;
236
+ }
237
+ const candidates = store.listAlarms();
238
+ const alarm = candidates.find(item => item.enabled) ?? candidates[0];
239
+ verificationRunningRef.current = true;
240
+ const initial = { state: 'running', alarmLabel: alarm?.label, startedAt: new Date().toISOString(), steps: [] };
241
+ setVerification(initial);
242
+ setMessage('Verifying the complete alarm path. A short audible sample and terminal window are expected.');
243
+ void service.verifySetup(alarm, library.settings, update => setVerification(update)).then(report => { setVerification(report); setMessage(report.state === 'passed' ? 'Alarm setup verified. This machine completed every rehearsal step.' : report.state === 'warning' ? 'Alarm setup works, with limitations shown in the verification report.' : 'Alarm setup verification found blockers. Review the failed steps before relying on it.'); }).catch(error => { setVerification(current => ({ ...current ?? initial, state: 'failed', finishedAt: new Date().toISOString(), steps: [...(current?.steps ?? []), { id: 'internal', label: 'Verification runner', state: 'failed', detail: messageOf(error), critical: true }] })); setMessage(`Alarm setup verification failed: ${messageOf(error)}`); }).finally(() => { verificationRunningRef.current = false; void refreshRuntime(); });
244
+ }, [library.settings, refreshRuntime, service, setMessage, store]);
245
+ const handleInput = useCallback((input, key) => {
246
+ if (!['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing'].includes(screen))
247
+ return false;
248
+ if (key.scrollDelta) {
249
+ if (screen === 'alarm-editor') {
250
+ if (editorControlRef.current === 'weekdays') {
251
+ const index = clamp(weekdayIndexRef.current + key.scrollDelta, 6);
252
+ weekdayIndexRef.current = index;
253
+ setWeekdayIndex(index);
254
+ return true;
255
+ }
256
+ if (editingField || editorControlRef.current)
257
+ return true;
258
+ setSelected(value => clamp(value + key.scrollDelta, visibleFields.length - 1));
259
+ }
260
+ else if (screen === 'alarm-ringing') {
261
+ const index = clamp(activeSelectedRef.current + key.scrollDelta, activeAlarms.length - 1);
262
+ activeSelectedRef.current = index;
263
+ activeSelectedKeyRef.current = activeAlarms[index]?.key;
264
+ setActiveSelected(index);
265
+ }
266
+ else {
267
+ const maximum = screen === 'alarms' ? alarmStateRef.current.length + 1 : pickerChoices.length - 1;
268
+ setSelected(value => clamp(value + key.scrollDelta, maximum));
269
+ }
270
+ return true;
271
+ }
272
+ if (screen === 'alarms') {
273
+ if (verification) {
274
+ if (verificationRunningRef.current) {
275
+ if (input || key.escape || key.return || key.leftArrow || key.rightArrow)
276
+ setMessage('Verification is still running; RadioCLI will return after cleanup finishes.');
277
+ return true;
278
+ }
279
+ if (input === 'b' || key.escape || key.return) {
280
+ setVerification(null);
281
+ setSelected(alarmStateRef.current.length + 1);
282
+ return true;
283
+ }
284
+ return false;
285
+ }
286
+ const verifyIndex = alarmStateRef.current.length + 1;
287
+ const alarm = alarmStateRef.current[selected - 1];
288
+ const alarmIsActive = Boolean(alarm && activeAlarmsRef.current.some(session => session.status.alarmId === alarm.id));
289
+ if (input === 'n') {
290
+ openForStation();
291
+ return true;
292
+ }
293
+ if (key.return && selected === 0) {
294
+ openForStation();
295
+ return true;
296
+ }
297
+ if (key.return && selected === verifyIndex) {
298
+ runVerification();
299
+ return true;
300
+ }
301
+ if (alarm && busyAlarmIdsRef.current.has(alarm.id)) {
302
+ if (key.downArrow || input === 'j') {
303
+ setSelected(value => clamp(value + 1, alarmStateRef.current.length + 1));
304
+ return true;
305
+ }
306
+ if (key.upArrow || input === 'k' || input === 'p') {
307
+ setSelected(value => clamp(value - 1, alarmStateRef.current.length + 1));
308
+ return true;
309
+ }
310
+ if (input === 'b' || key.escape) {
311
+ go('home');
312
+ return true;
313
+ }
314
+ if (key.return || ['e', ' ', 'x', 't', 'g'].includes(input)) {
315
+ setMessage(`Cleanup is still running for ${alarm.label}.`);
316
+ return true;
317
+ }
318
+ }
319
+ if ((key.return || input === 'e') && alarm) {
320
+ editSelected();
321
+ return true;
322
+ }
323
+ if (input === ' ' && alarm) {
324
+ const updated = store.toggleAlarm(alarm.id);
325
+ const snapshot = store.snapshot();
326
+ alarmStateRef.current = snapshot.alarms;
327
+ setLibrary(snapshot);
328
+ void syncPersisted(updated, `${updated.enabled ? 'Enabled' : 'Disabled'} ${updated.label}.`);
329
+ return true;
330
+ }
331
+ if (input === 'x' && alarm) {
332
+ if (alarmIsActive) {
333
+ setMessage(`Dismiss or snooze ${alarm.label} before deleting it; current playback was left untouched.`);
334
+ return true;
335
+ }
336
+ if (deletingIdRef.current !== alarm.id) {
337
+ deletingIdRef.current = alarm.id;
338
+ setDeletingId(alarm.id);
339
+ setMessage(`Press x again to delete “${alarm.label}”.`);
340
+ return true;
341
+ }
342
+ deletingIdRef.current = undefined;
343
+ setDeletingId(undefined);
344
+ busyAlarmIdsRef.current.add(alarm.id);
345
+ setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
346
+ void service.remove(alarm).then(() => {
347
+ if (store.getAlarm(alarm.id))
348
+ store.removeAlarm(alarm.id);
349
+ const snapshot = store.snapshot();
350
+ alarmStateRef.current = snapshot.alarms;
351
+ setLibrary(snapshot);
352
+ setSelected(value => clamp(value, snapshot.alarms.length + 1));
353
+ setMessage(`Deleted ${alarm.label}.`);
354
+ }).catch(error => {
355
+ const existing = store.getAlarm(alarm.id);
356
+ if (existing) {
357
+ const disabled = store.toggleAlarm(alarm.id, false);
358
+ const snapshot = store.snapshot();
359
+ alarmStateRef.current = snapshot.alarms;
360
+ setLibrary(snapshot);
361
+ setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`);
362
+ }
363
+ else
364
+ setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`);
365
+ }).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); });
366
+ return true;
367
+ }
368
+ if (input !== 'x') {
369
+ deletingIdRef.current = undefined;
370
+ setDeletingId(undefined);
371
+ }
372
+ if (input === 't' && alarm) {
373
+ void previewStation(alarm.station).then(() => setMessage(`Test-tuning ${alarm.station.name}; no alarm state changed.`)).catch(error => setMessage(`Test tune failed: ${messageOf(error)}`));
374
+ return true;
375
+ }
376
+ if (input === 'r') {
377
+ const activeIds = new Set(activeAlarmsRef.current.map(session => session.status.alarmId));
378
+ const repairable = store.listAlarms().filter(item => !busyAlarmIdsRef.current.has(item.id) && !activeIds.has(item.id));
379
+ void (async () => { let terminalWarning; try {
380
+ await service.prepareTerminalAccess?.();
381
+ }
382
+ catch (error) {
383
+ terminalWarning = messageOf(error);
384
+ } try {
385
+ const results = await service.syncAll(repairable);
386
+ const failed = results.filter(item => item.error);
387
+ setMessage(failed.length ? `${failed.length} alarms still degraded: ${failed[0].error}` : terminalWarning ? `Native jobs repaired, but automatic terminal controls need attention: ${terminalWarning}` : activeIds.size ? 'Inactive alarms repaired; currently playing alarms were left untouched.' : 'All alarms repaired and synchronized.');
388
+ void refreshRuntime();
389
+ }
390
+ catch (error) {
391
+ setMessage(`Alarm repair failed: ${messageOf(error)}`);
392
+ } })();
393
+ return true;
394
+ }
395
+ if (input === 'g' && alarm) {
396
+ const updated = store.updateAlarm(alarm.id, { reliability: { ...alarm.reliability, keepAwakeUntilAlarm: !alarm.reliability.keepAwakeUntilAlarm } });
397
+ const snapshot = store.snapshot();
398
+ alarmStateRef.current = snapshot.alarms;
399
+ setLibrary(snapshot);
400
+ void syncPersisted(updated, `Alarm Guard ${updated.reliability.keepAwakeUntilAlarm ? 'on' : 'off'} for ${alarm.label}.`);
401
+ return true;
402
+ }
403
+ if (input === 'b' || key.escape) {
404
+ go('home');
405
+ return true;
406
+ }
407
+ if (key.downArrow || input === 'j') {
408
+ setSelected(value => clamp(value + 1, library.alarms.length + 1));
409
+ return true;
410
+ }
411
+ if (key.upArrow || input === 'k' || input === 'p') {
412
+ setSelected(value => clamp(value - 1, library.alarms.length + 1));
413
+ return true;
414
+ }
415
+ return false;
416
+ }
417
+ if (screen === 'alarm-picker') {
418
+ if (key.escape || input === 'b') {
419
+ go('alarm-editor', { clearMessage: false });
420
+ setSelected(visibleFields.indexOf(pickerFallback ? 'fallbackStation' : 'station'));
421
+ return true;
422
+ }
423
+ if (key.downArrow || input === 'j') {
424
+ setSelected(value => clamp(value + 1, pickerChoices.length - 1));
425
+ return true;
426
+ }
427
+ if (key.upArrow || input === 'k') {
428
+ setSelected(value => clamp(value - 1, pickerChoices.length - 1));
429
+ return true;
430
+ }
431
+ if (key.return) {
432
+ const choice = pickerChoices[selected];
433
+ if (choice && draft)
434
+ setDraft({ ...draft, [pickerFallback ? 'fallbackStation' : 'station']: choice.station });
435
+ go('alarm-editor', { clearMessage: false });
436
+ setSelected(visibleFields.indexOf(pickerFallback ? 'fallbackStation' : 'station'));
437
+ return true;
438
+ }
439
+ return false;
440
+ }
441
+ if (screen === 'alarm-ringing') {
442
+ if (input === 'b' || key.escape) {
443
+ go(returnScreenRef.current === 'alarm-ringing' ? 'home' : returnScreenRef.current);
444
+ return true;
445
+ }
446
+ if (key.downArrow || input === 'j') {
447
+ const index = clamp(activeSelectedRef.current + 1, activeAlarms.length - 1);
448
+ activeSelectedRef.current = index;
449
+ activeSelectedKeyRef.current = activeAlarms[index]?.key;
450
+ setActiveSelected(index);
451
+ return true;
452
+ }
453
+ if (key.upArrow) {
454
+ const index = clamp(activeSelectedRef.current - 1, activeAlarms.length - 1);
455
+ activeSelectedRef.current = index;
456
+ activeSelectedKeyRef.current = activeAlarms[index]?.key;
457
+ setActiveSelected(index);
458
+ return true;
459
+ }
460
+ const active = activeAlarms[activeSelectedRef.current];
461
+ if (!active)
462
+ return false;
463
+ if (key.return) {
464
+ if (active.status.state === 'starting') {
465
+ setMessage(`Still preparing ${active.status.stationName}; handoff will be available once playback starts.`);
466
+ return true;
467
+ }
468
+ if (pendingActiveActionsRef.current.has(active.key)) {
469
+ setMessage(`An alarm control request is already pending for ${active.status.stationName}.`);
470
+ return true;
471
+ }
472
+ const station = active.status.station ?? store.getAlarm(active.status.alarmId)?.station;
473
+ if (!station) {
474
+ setMessage('This older alarm session cannot transfer into interactive playback. Snooze it or return to the app.');
475
+ return true;
476
+ }
477
+ pendingActiveActionsRef.current.add(active.key);
478
+ void (async () => {
479
+ try {
480
+ await previewStation(station);
481
+ if (active.handoff)
482
+ await active.handoff();
483
+ else
484
+ await active.dismiss();
485
+ acknowledgedActiveKeysRef.current.add(active.key);
486
+ const remaining = activeAlarmsRef.current.filter(session => session.key !== active.key);
487
+ activeAlarmsRef.current = remaining;
488
+ setActiveAlarms(remaining);
489
+ activeCountRef.current = remaining.length;
490
+ activeSelectedRef.current = 0;
491
+ activeSelectedKeyRef.current = remaining[0]?.key;
492
+ setActiveSelected(0);
493
+ setMessage(null);
494
+ go('now-playing', { resetSelection: true, clearMessage: false });
495
+ }
496
+ catch (error) {
497
+ setMessage(`Playback handoff failed; the alarm is still available: ${messageOf(error)}`);
498
+ }
499
+ finally {
500
+ pendingActiveActionsRef.current.delete(active.key);
501
+ }
502
+ })();
503
+ return true;
504
+ }
505
+ const actionKind = input === ' ' ? 'snooze' : null;
506
+ if (actionKind && pendingActiveActionsRef.current.has(active.key)) {
507
+ setMessage(`An alarm control request is already pending for ${active.status.stationName}.`);
508
+ return true;
509
+ }
510
+ const action = actionKind === 'snooze' ? active.snooze(snoozeMinutes) : null;
511
+ if (action) {
512
+ pendingActiveActionsRef.current.add(active.key);
513
+ void action.then(() => { setMessage(`Snoozed ${snoozeMinutes} minutes; it will ring again at ${formatSnoozeTime(snoozeMinutes)}.`); const remaining = activeAlarmsRef.current.filter(session => session.key !== active.key); activeAlarmsRef.current = remaining; setActiveAlarms(remaining); activeCountRef.current = remaining.length; activeSelectedRef.current = 0; activeSelectedKeyRef.current = remaining[0]?.key; setActiveSelected(0); if (remaining.length === 0)
514
+ go('alarms', { resetSelection: true, clearMessage: false }); }).catch(error => { pendingActiveActionsRef.current.delete(active.key); setMessage(`Alarm control failed: ${messageOf(error)}`); });
515
+ return true;
516
+ }
517
+ return false;
518
+ }
519
+ if (!draft) {
520
+ go('alarms');
521
+ return true;
522
+ }
523
+ if ((key.ctrl || key.meta) && input.toLowerCase() === 's') {
524
+ void saveDraft();
525
+ return true;
526
+ }
527
+ if (editorControlRef.current === 'time') {
528
+ if (key.escape || input === 'b') {
529
+ editorControlRef.current = null;
530
+ setEditorControl(null);
531
+ return true;
532
+ }
533
+ if (key.return) {
534
+ if (timeSegmentRef.current === 'hour') {
535
+ timeSegmentRef.current = 'minute';
536
+ setTimeSegment('minute');
537
+ }
538
+ else {
539
+ editorControlRef.current = null;
540
+ setEditorControl(null);
541
+ }
542
+ return true;
543
+ }
544
+ if (key.leftArrow) {
545
+ timeSegmentRef.current = 'hour';
546
+ setTimeSegment('hour');
547
+ return true;
548
+ }
549
+ if (key.rightArrow) {
550
+ timeSegmentRef.current = 'minute';
551
+ setTimeSegment('minute');
552
+ return true;
553
+ }
554
+ if (key.upArrow || key.downArrow || input === 'j' || input === 'k') {
555
+ const direction = key.upArrow || input === 'k' ? 1 : -1;
556
+ setDraft(value => value ? { ...value, time: adjustTime(value.time, timeSegmentRef.current, direction) } : value);
557
+ return true;
558
+ }
559
+ return true;
560
+ }
561
+ if (editorControlRef.current === 'weekdays') {
562
+ if (key.escape || key.leftArrow || input === 'b') {
563
+ editorControlRef.current = null;
564
+ setEditorControl(null);
565
+ return true;
566
+ }
567
+ if (key.return) {
568
+ editorControlRef.current = null;
569
+ setEditorControl(null);
570
+ return true;
571
+ }
572
+ if (key.downArrow || input === 'j') {
573
+ const next = clamp(weekdayIndexRef.current + 1, 6);
574
+ weekdayIndexRef.current = next;
575
+ setWeekdayIndex(next);
576
+ return true;
577
+ }
578
+ if (key.upArrow || input === 'k') {
579
+ const next = clamp(weekdayIndexRef.current - 1, 6);
580
+ weekdayIndexRef.current = next;
581
+ setWeekdayIndex(next);
582
+ return true;
583
+ }
584
+ if (input === ' ') {
585
+ setDraft(value => value ? { ...value, weekdays: toggleWeekday(value.weekdays, (weekdayIndexRef.current + 1)) } : value);
586
+ return true;
587
+ }
588
+ return true;
589
+ }
590
+ if (editorControlRef.current === 'number') {
591
+ if (key.escape || key.return || input === 'b') {
592
+ editorControlRef.current = null;
593
+ setEditorControl(null);
594
+ return true;
595
+ }
596
+ if (key.leftArrow || key.downArrow || input === 'j') {
597
+ adjustDraft(editorField, -1, setDraft);
598
+ return true;
599
+ }
600
+ if (key.rightArrow || key.upArrow || input === 'k') {
601
+ adjustDraft(editorField, 1, setDraft);
602
+ return true;
603
+ }
604
+ return true;
605
+ }
606
+ if (editingField) {
607
+ if (key.return) {
608
+ setEditingField(false);
609
+ setValidationError(validateAlarmDraft(draft));
610
+ return true;
611
+ }
612
+ if (key.escape) {
613
+ setEditingField(false);
614
+ return true;
615
+ }
616
+ if (alarmTextFields.has(editorField) && isEditableInput(input, key)) {
617
+ setDraft(value => value ? { ...value, [editorField]: applyTextInput(textFieldValue(value, editorField), input, key) } : value);
618
+ }
619
+ return true;
620
+ }
621
+ if (key.escape || input === 'b') {
622
+ go('alarms');
623
+ return true;
624
+ }
625
+ if (key.downArrow || input === 'j') {
626
+ editorControlRef.current = null;
627
+ setEditorControl(null);
628
+ setSelected(value => clamp(value + 1, visibleFields.length - 1));
629
+ return true;
630
+ }
631
+ if (key.upArrow || input === 'k') {
632
+ editorControlRef.current = null;
633
+ setEditorControl(null);
634
+ setSelected(value => clamp(value - 1, visibleFields.length - 1));
635
+ return true;
636
+ }
637
+ if (key.leftArrow || key.rightArrow) {
638
+ if (editorField === 'time') {
639
+ const segment = key.leftArrow ? 'hour' : 'minute';
640
+ timeSegmentRef.current = segment;
641
+ setTimeSegment(segment);
642
+ editorControlRef.current = 'time';
643
+ setEditorControl('time');
644
+ }
645
+ else
646
+ adjustDraft(editorField, key.rightArrow ? 1 : -1, setDraft);
647
+ return true;
648
+ }
649
+ if (key.return) {
650
+ if (alarmTextFields.has(editorField))
651
+ setEditingField(true);
652
+ else if (editorField === 'time') {
653
+ editorControlRef.current = 'time';
654
+ setEditorControl('time');
655
+ }
656
+ else if (editorField === 'weekdays') {
657
+ editorControlRef.current = 'weekdays';
658
+ setEditorControl('weekdays');
659
+ }
660
+ else if (numericEditorFields.has(editorField)) {
661
+ editorControlRef.current = 'number';
662
+ setEditorControl('number');
663
+ }
664
+ else if (editorField === 'station' || editorField === 'fallbackStation') {
665
+ setPickerFallback(editorField === 'fallbackStation');
666
+ setSelected(0);
667
+ go('alarm-picker', { resetSelection: true, clearMessage: false });
668
+ }
669
+ else if (editorField === 'preview' && draft.station)
670
+ void previewStation(draft.station).then(() => setMessage(`Test-tuning ${draft.station.name}; no alarm state changed.`)).catch(error => setMessage(`Test tune failed: ${messageOf(error)}`));
671
+ else if (editorField === 'save')
672
+ void saveDraft();
673
+ else if (editorField === 'cancel')
674
+ go('alarms');
675
+ else
676
+ adjustDraft(editorField, 1, setDraft);
677
+ return true;
678
+ }
679
+ return false;
680
+ }, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
681
+ const openActive = useCallback(() => {
682
+ if (!activeAlarms.length) {
683
+ setMessage('No alarm is currently playing.');
684
+ return;
685
+ }
686
+ if (screenRef.current !== 'alarm-ringing')
687
+ returnScreenRef.current = screenRef.current;
688
+ activeSelectedRef.current = 0;
689
+ activeSelectedKeyRef.current = activeAlarms[0]?.key;
690
+ setActiveSelected(0);
691
+ go('alarm-ringing', { resetSelection: true, clearMessage: false });
692
+ }, [activeAlarms, go, setMessage]);
693
+ return { draft, editorField, editingField, editorControl, timeSegment, weekdayIndex, validationError, saving, pickerChoices, pickerFallback, runtime, verification, activeAlarms, activeSelected, snoozeMinutes, deletingId, busyAlarmIds, itemCount(current) { if (current === 'alarms')
694
+ return library.alarms.length + 2; if (current === 'alarm-editor')
695
+ return visibleFields.length; if (current === 'alarm-picker')
696
+ return pickerChoices.length; if (current === 'alarm-ringing')
697
+ return activeAlarms.length; return undefined; }, handleInput, openForStation, openActive };
698
+ }
699
+ const numericEditorFields = new Set(['volume', 'fadeSeconds', 'stopAfterMinutes', 'missedRunGraceMinutes']);
700
+ function adjustDraft(field, direction, setDraft) {
701
+ setDraft(draft => {
702
+ if (!draft)
703
+ return draft;
704
+ if (field === 'enabled' || field === 'wakeIfSupported' || field === 'keepAwakeUntilAlarm')
705
+ return { ...draft, [field]: !draft[field] };
706
+ if (field === 'scheduleType')
707
+ return { ...draft, scheduleType: draft.scheduleType === 'once' ? 'recurring' : 'once' };
708
+ if (field === 'weekdays')
709
+ return { ...draft, weekdays: cycleWeekdays(draft.weekdays, direction) };
710
+ if (field === 'volume')
711
+ return { ...draft, volume: String(clamp(Number(draft.volume) + direction * 5, 100)) };
712
+ if (field === 'fadeSeconds')
713
+ return { ...draft, fadeSeconds: String(clamp(Number(draft.fadeSeconds) + direction * 5, 3600)) };
714
+ if (field === 'stopAfterMinutes')
715
+ return { ...draft, stopAfterMinutes: String(clamp(Number(draft.stopAfterMinutes) + direction * 5, 10080)) };
716
+ if (field === 'missedRunGraceMinutes')
717
+ return { ...draft, missedRunGraceMinutes: String(clamp(Number(draft.missedRunGraceMinutes) + direction * 5, 10080)) };
718
+ return draft;
719
+ });
720
+ }
721
+ function messageOf(error) { return error instanceof Error ? error.message : String(error); }
722
+ function formatSnoozeTime(minutes) { return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date(Date.now() + minutes * 60_000)); }
723
+ function textFieldValue(draft, field) {
724
+ if (field === 'label' || field === 'date' || field === 'time' || field === 'weekdays' || field === 'timezone' || field === 'volume' || field === 'fadeSeconds' || field === 'stopAfterMinutes' || field === 'missedRunGraceMinutes')
725
+ return draft[field];
726
+ return '';
727
+ }