@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.
- package/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
package/dist/ui/AppContent.js
CHANGED
|
@@ -14,15 +14,32 @@ import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
|
|
|
14
14
|
import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
|
|
15
15
|
import { selectedAirPlayDevice } from './airplay-settings.js';
|
|
16
16
|
import { AdaptiveContent } from './AdaptiveContent.js';
|
|
17
|
-
|
|
17
|
+
import { AlarmsScreen, AlarmEditorScreen, AlarmPickerScreen, AlarmRingingScreen } from './screens/AlarmsScreen.js';
|
|
18
|
+
export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui }) {
|
|
18
19
|
if (layout.compact) {
|
|
19
|
-
|
|
20
|
+
if (screen === 'alarms')
|
|
21
|
+
return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
|
|
22
|
+
if (screen === 'alarm-editor' && alarmTui.draft)
|
|
23
|
+
return _jsx(AlarmEditorScreen, { draft: alarmTui.draft, field: alarmTui.editorField, editing: alarmTui.editingField, control: alarmTui.editorControl, timeSegment: alarmTui.timeSegment, weekdayIndex: alarmTui.weekdayIndex, error: alarmTui.validationError, saving: alarmTui.saving, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
24
|
+
if (screen === 'alarm-picker')
|
|
25
|
+
return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
26
|
+
if (screen === 'alarm-ringing')
|
|
27
|
+
return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
28
|
+
return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel }));
|
|
20
29
|
}
|
|
30
|
+
if (screen === 'alarms')
|
|
31
|
+
return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
|
|
32
|
+
if (screen === 'alarm-editor' && alarmTui.draft)
|
|
33
|
+
return _jsx(AlarmEditorScreen, { draft: alarmTui.draft, field: alarmTui.editorField, editing: alarmTui.editingField, control: alarmTui.editorControl, timeSegment: alarmTui.timeSegment, weekdayIndex: alarmTui.weekdayIndex, error: alarmTui.validationError, saving: alarmTui.saving, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
34
|
+
if (screen === 'alarm-picker')
|
|
35
|
+
return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
36
|
+
if (screen === 'alarm-ringing')
|
|
37
|
+
return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
21
38
|
if (screen === 'home') {
|
|
22
39
|
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library });
|
|
23
40
|
}
|
|
24
41
|
if (screen === 'countries') {
|
|
25
|
-
return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth }));
|
|
42
|
+
return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth, reduceMotion: Boolean(library.settings.reduceMotion) }));
|
|
26
43
|
}
|
|
27
44
|
if (screen === 'map') {
|
|
28
45
|
return (_jsx(MapScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.mapCountryRows, mode: layout.mapMode, width: frameWidth }));
|
|
@@ -31,19 +48,19 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
|
|
|
31
48
|
return (_jsx(SearchScreen, { query: searchQuery, editing: editingSearch, loading: loadingStations, stations: displayStations, selected: selected, theme: theme, favorites: favoriteKeys, experimentalOn: library.settings.enableRadioGarden, filterLabel: filterLabel, pageSize: Math.max(1, layout.contentRows - 8), width: frameWidth }));
|
|
32
49
|
}
|
|
33
50
|
if (screen === 'explore') {
|
|
34
|
-
return (_jsx(ExploreScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, cursor: exploreCursor, pageSize: layout.stationRows, width: frameWidth, height: layout.contentRows }));
|
|
51
|
+
return (_jsx(ExploreScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, error: stationContext.error, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, cursor: exploreCursor, pageSize: layout.stationRows, width: frameWidth, height: layout.contentRows }));
|
|
35
52
|
}
|
|
36
53
|
if (screen === 'nearby' || screen === 'stations' || screen === 'library') {
|
|
37
|
-
return (_jsx(StationScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, pageSize: layout.stationRows, width: frameWidth }));
|
|
54
|
+
return (_jsx(StationScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, error: stationContext.error, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, pageSize: layout.stationRows, width: frameWidth }));
|
|
38
55
|
}
|
|
39
56
|
if (screen === 'now-playing') {
|
|
40
|
-
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite,
|
|
57
|
+
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, diagnostics: diagnostics, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, trackHistory: library.trackHistory, width: layout.receiverWidth, height: layout.receiverRows }));
|
|
41
58
|
}
|
|
42
59
|
if (screen === 'stats') {
|
|
43
60
|
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
44
61
|
}
|
|
45
62
|
if (screen === 'settings') {
|
|
46
|
-
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
|
|
63
|
+
return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
|
|
47
64
|
}
|
|
48
65
|
if (screen === 'airplay-settings') {
|
|
49
66
|
return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function adaptiveExploreFrameMetrics(mode, height) {
|
|
2
|
+
const safeHeight = Math.max(1, height);
|
|
3
|
+
const headerRows = mode === 'compact' && safeHeight >= 4 ? 2 : 1;
|
|
4
|
+
const headerGap = mode === 'compact' && safeHeight >= 5 ? 1 : 0;
|
|
5
|
+
return { headerRows, headerGap, bodyRows: Math.max(0, safeHeight - headerRows - headerGap) };
|
|
6
|
+
}
|
|
7
|
+
export function computeAdaptiveExploreLayout(mode, width, bodyRows) {
|
|
8
|
+
const safeWidth = Math.max(1, width);
|
|
9
|
+
const safeRows = Math.max(1, bodyRows);
|
|
10
|
+
// Compact/mini always keeps the map and list side-by-side. Only true micro
|
|
11
|
+
// stacks stations below the map.
|
|
12
|
+
const split = mode === 'compact';
|
|
13
|
+
if (split) {
|
|
14
|
+
const gap = 1;
|
|
15
|
+
const mapAreaWidth = Math.min(Math.max(1, safeWidth - 7), Math.max(24, Math.floor(safeWidth * 0.62)));
|
|
16
|
+
const listWidth = Math.max(1, safeWidth - mapAreaWidth - gap);
|
|
17
|
+
const mapRows = safeRows;
|
|
18
|
+
const mapColumns = Math.max(1, Math.min(mapAreaWidth, mapRows * 4));
|
|
19
|
+
return {
|
|
20
|
+
split,
|
|
21
|
+
mapAreaWidth,
|
|
22
|
+
mapColumns,
|
|
23
|
+
mapRows,
|
|
24
|
+
mapOffsetX: Math.max(0, Math.floor((mapAreaWidth - mapColumns) / 2)),
|
|
25
|
+
listWidth,
|
|
26
|
+
listRows: safeRows,
|
|
27
|
+
gap
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// True micro stacks a detailed map above a height-aware list. Reserve one,
|
|
31
|
+
// two, or three station rows as space permits while leaving most of the
|
|
32
|
+
// viewport available to the map.
|
|
33
|
+
const listRows = Math.min(3, Math.max(1, Math.floor(safeRows / 3)));
|
|
34
|
+
const gap = 0;
|
|
35
|
+
const mapRows = Math.max(1, safeRows - listRows - gap);
|
|
36
|
+
const mapColumns = Math.max(1, Math.min(safeWidth, mapRows * 4));
|
|
37
|
+
return {
|
|
38
|
+
split,
|
|
39
|
+
mapAreaWidth: safeWidth,
|
|
40
|
+
mapColumns,
|
|
41
|
+
mapRows,
|
|
42
|
+
mapOffsetX: Math.max(0, Math.floor((safeWidth - mapColumns) / 2)),
|
|
43
|
+
listWidth: safeWidth,
|
|
44
|
+
listRows,
|
|
45
|
+
gap
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { nextAlarmOccurrence } from '../alarms/schedule.js';
|
|
2
|
+
export const alarmEditorFields = [
|
|
3
|
+
'label', 'enabled', 'station', 'scheduleType', 'date', 'time', 'weekdays', 'timezone',
|
|
4
|
+
'volume', 'fadeSeconds', 'stopAfterMinutes', 'fallbackStation', 'output',
|
|
5
|
+
'missedRunGraceMinutes', 'wakeIfSupported', 'keepAwakeUntilAlarm', 'preview', 'save', 'cancel'
|
|
6
|
+
];
|
|
7
|
+
export const alarmTextFields = new Set([
|
|
8
|
+
'label', 'date', 'timezone'
|
|
9
|
+
]);
|
|
10
|
+
export function adjustTime(value, segment, direction) {
|
|
11
|
+
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
|
12
|
+
const hour = match ? Number(match[1]) : 0;
|
|
13
|
+
const minute = match ? Number(match[2]) : 0;
|
|
14
|
+
const nextHour = segment === 'hour' ? wrap(hour + direction, 24) : hour;
|
|
15
|
+
const nextMinute = segment === 'minute' ? wrap(minute + direction, 60) : minute;
|
|
16
|
+
return `${String(nextHour).padStart(2, '0')}:${String(nextMinute).padStart(2, '0')}`;
|
|
17
|
+
}
|
|
18
|
+
export function toggleWeekday(value, weekday) {
|
|
19
|
+
const selected = new Set(parseWeekdayValues(value));
|
|
20
|
+
if (selected.has(weekday)) {
|
|
21
|
+
if (selected.size > 1)
|
|
22
|
+
selected.delete(weekday);
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
selected.add(weekday);
|
|
26
|
+
return [...selected].sort((left, right) => left - right).join(',');
|
|
27
|
+
}
|
|
28
|
+
export function defaultAlarmDraft(station, now = new Date()) {
|
|
29
|
+
const future = new Date(now.getTime() + 5 * 60_000);
|
|
30
|
+
future.setSeconds(0, 0);
|
|
31
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
32
|
+
return {
|
|
33
|
+
label: station ? `Wake to ${station.name}` : 'Morning radio',
|
|
34
|
+
enabled: true,
|
|
35
|
+
station,
|
|
36
|
+
scheduleType: 'recurring',
|
|
37
|
+
date: localDate(future),
|
|
38
|
+
time: localTime(future),
|
|
39
|
+
weekdays: '1,2,3,4,5',
|
|
40
|
+
timezone,
|
|
41
|
+
volume: '70',
|
|
42
|
+
fadeSeconds: '30',
|
|
43
|
+
stopAfterMinutes: '60',
|
|
44
|
+
missedRunGraceMinutes: '15',
|
|
45
|
+
wakeIfSupported: true,
|
|
46
|
+
keepAwakeUntilAlarm: false
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function draftFromAlarm(alarm) {
|
|
50
|
+
const once = alarm.schedule.type === 'once' ? new Date(alarm.schedule.at) : undefined;
|
|
51
|
+
return {
|
|
52
|
+
id: alarm.id,
|
|
53
|
+
label: alarm.label,
|
|
54
|
+
enabled: alarm.enabled,
|
|
55
|
+
station: alarm.station,
|
|
56
|
+
scheduleType: alarm.schedule.type,
|
|
57
|
+
date: once ? localDate(once) : localDate(new Date()),
|
|
58
|
+
time: alarm.schedule.type === 'once' ? localTime(once) : alarm.schedule.time,
|
|
59
|
+
weekdays: alarm.schedule.type === 'recurring' ? alarm.schedule.weekdays.join(',') : '1,2,3,4,5',
|
|
60
|
+
timezone: alarm.schedule.type === 'recurring' ? alarm.schedule.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
|
61
|
+
volume: String(alarm.playback.volume),
|
|
62
|
+
fadeSeconds: String(alarm.playback.fadeSeconds),
|
|
63
|
+
stopAfterMinutes: String(alarm.playback.stopAfterMinutes),
|
|
64
|
+
fallbackStation: alarm.playback.fallbackStation,
|
|
65
|
+
missedRunGraceMinutes: String(alarm.reliability.missedRunGraceMinutes),
|
|
66
|
+
wakeIfSupported: alarm.reliability.wakeIfSupported,
|
|
67
|
+
keepAwakeUntilAlarm: Boolean(alarm.reliability.keepAwakeUntilAlarm)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function validateAlarmDraft(draft, now = new Date()) {
|
|
71
|
+
try {
|
|
72
|
+
alarmInputFromDraft(draft, now);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return error instanceof Error ? error.message : String(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function alarmInputFromDraft(draft, now = new Date()) {
|
|
80
|
+
const label = draft.label.trim();
|
|
81
|
+
if (!label)
|
|
82
|
+
throw new Error('Label is required.');
|
|
83
|
+
if (!draft.station)
|
|
84
|
+
throw new Error('Choose a primary station.');
|
|
85
|
+
const volume = integer(draft.volume, 'Volume', 0, 100);
|
|
86
|
+
const fadeSeconds = integer(draft.fadeSeconds, 'Fade', 0, 3600);
|
|
87
|
+
const stopAfterMinutes = integer(draft.stopAfterMinutes, 'Stop after', 1, 10080);
|
|
88
|
+
const missedRunGraceMinutes = integer(draft.missedRunGraceMinutes, 'Missed grace', 0, 10080);
|
|
89
|
+
const schedule = draft.scheduleType === 'once'
|
|
90
|
+
? { type: 'once', at: onceInstant(draft.date, draft.time, canonicalTimezone(draft.timezone)) }
|
|
91
|
+
: {
|
|
92
|
+
type: 'recurring',
|
|
93
|
+
time: canonicalTime(draft.time),
|
|
94
|
+
weekdays: parseWeekdays(draft.weekdays),
|
|
95
|
+
timezone: canonicalTimezone(draft.timezone)
|
|
96
|
+
};
|
|
97
|
+
if (schedule.type === 'once' && new Date(schedule.at).getTime() <= now.getTime())
|
|
98
|
+
throw new Error('One-time alarm must be in the future.');
|
|
99
|
+
return {
|
|
100
|
+
label,
|
|
101
|
+
enabled: draft.enabled,
|
|
102
|
+
station: draft.station,
|
|
103
|
+
schedule,
|
|
104
|
+
playback: { volume, fadeSeconds, stopAfterMinutes, ...(draft.fallbackStation ? { fallbackStation: draft.fallbackStation } : {}) },
|
|
105
|
+
reliability: { missedRunGraceMinutes, wakeIfSupported: draft.wakeIfSupported, keepAwakeUntilAlarm: draft.keepAwakeUntilAlarm }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function draftNextOccurrence(draft, now = new Date()) {
|
|
109
|
+
try {
|
|
110
|
+
return nextAlarmOccurrence(alarmInputFromDraft({ ...draft, enabled: true }, new Date(0)).schedule, now);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function cycleWeekdays(value, direction) {
|
|
117
|
+
const presets = ['1,2,3,4,5', '6,7', '1,2,3,4,5,6,7'];
|
|
118
|
+
const index = presets.indexOf(value);
|
|
119
|
+
return presets[(index < 0 ? 0 : index + direction + presets.length) % presets.length];
|
|
120
|
+
}
|
|
121
|
+
function parseWeekdays(value) {
|
|
122
|
+
const days = [...new Set(value.split(',').map(part => Number(part.trim())))].sort((a, b) => a - b);
|
|
123
|
+
if (!days.length || days.some(day => !Number.isInteger(day) || day < 1 || day > 7))
|
|
124
|
+
throw new Error('Weekdays use 1–7 (Monday–Sunday), separated by commas.');
|
|
125
|
+
return days;
|
|
126
|
+
}
|
|
127
|
+
function parseWeekdayValues(value) {
|
|
128
|
+
return value.split(',').map(Number).filter(day => Number.isInteger(day) && day >= 1 && day <= 7);
|
|
129
|
+
}
|
|
130
|
+
function wrap(value, size) { return ((value % size) + size) % size; }
|
|
131
|
+
function canonicalTime(value) { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value))
|
|
132
|
+
throw new Error('Time must use 24-hour HH:mm.'); return value; }
|
|
133
|
+
function canonicalTimezone(value) { try {
|
|
134
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value.trim() }).resolvedOptions().timeZone;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new Error('Enter a valid IANA timezone, such as America/Los_Angeles.');
|
|
138
|
+
} }
|
|
139
|
+
function onceInstant(date, time, timezone) {
|
|
140
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
141
|
+
if (!match)
|
|
142
|
+
throw new Error('Date must use YYYY-MM-DD.');
|
|
143
|
+
canonicalTime(time);
|
|
144
|
+
const year = Number(match[1]);
|
|
145
|
+
const month = Number(match[2]);
|
|
146
|
+
const day = Number(match[3]);
|
|
147
|
+
const normalized = new Date(Date.UTC(year, month - 1, day));
|
|
148
|
+
if (normalized.getUTCFullYear() !== year || normalized.getUTCMonth() + 1 !== month || normalized.getUTCDate() !== day)
|
|
149
|
+
throw new Error('Enter a real calendar date.');
|
|
150
|
+
const [hour, minute] = time.split(':').map(Number);
|
|
151
|
+
const estimate = Date.UTC(year, month - 1, day, hour, minute);
|
|
152
|
+
const formatter = civilFormatter(timezone);
|
|
153
|
+
const probes = [estimate - 48 * 60 * 60_000, estimate, estimate + 48 * 60 * 60_000];
|
|
154
|
+
const offsets = new Set(probes.map(timestamp => timezoneOffsetAt(timestamp, formatter)));
|
|
155
|
+
const candidates = [...offsets].map(offset => new Date(estimate - offset)).filter(candidate => {
|
|
156
|
+
const parts = civilParts(candidate, formatter);
|
|
157
|
+
return parts.year === year && parts.month === month && parts.day === day && parts.hour === hour && parts.minute === minute;
|
|
158
|
+
}).sort((left, right) => left.getTime() - right.getTime());
|
|
159
|
+
if (candidates.length === 0)
|
|
160
|
+
throw new Error('That local time does not exist because of a daylight-saving transition. Choose another time.');
|
|
161
|
+
if (candidates.length > 1)
|
|
162
|
+
throw new Error('That local time occurs twice because of a daylight-saving transition. Choose an unambiguous time.');
|
|
163
|
+
return candidates[0].toISOString();
|
|
164
|
+
}
|
|
165
|
+
function civilFormatter(timezone) { return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }); }
|
|
166
|
+
function civilParts(date, formatter) {
|
|
167
|
+
const values = Object.fromEntries(formatter.formatToParts(date).filter(part => part.type !== 'literal').map(part => [part.type, Number(part.value)]));
|
|
168
|
+
return { year: values.year ?? 0, month: values.month ?? 0, day: values.day ?? 0, hour: values.hour ?? 0, minute: values.minute ?? 0 };
|
|
169
|
+
}
|
|
170
|
+
function timezoneOffsetAt(timestamp, formatter) { const parts = civilParts(new Date(timestamp), formatter); return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute) - timestamp; }
|
|
171
|
+
function integer(value, label, min, max) { const number = Number(value); if (!Number.isInteger(number) || number < min || number > max)
|
|
172
|
+
throw new Error(`${label} must be ${min}–${max}.`); return number; }
|
|
173
|
+
function localDate(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; }
|
|
174
|
+
function localTime(date) { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; }
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
2
|
+
import { createSchedulerService } from '../alarms/scheduler.js';
|
|
3
|
+
import { prepareAlarmTerminalAccess } from '../alarms/terminal-launcher.js';
|
|
4
|
+
import { verifyAlarmSetup } from '../alarms/setup-verification.js';
|
|
5
|
+
export function createAlarmTuiService() {
|
|
6
|
+
const scheduler = createSchedulerService();
|
|
7
|
+
return serializeAlarmTuiService({
|
|
8
|
+
sync: alarm => scheduler.sync(alarm),
|
|
9
|
+
syncAll: alarms => scheduler.syncAll(alarms),
|
|
10
|
+
async remove(alarm) {
|
|
11
|
+
await scheduler.sync({ ...alarm, enabled: false });
|
|
12
|
+
scheduler.health.remove(alarm.id);
|
|
13
|
+
},
|
|
14
|
+
async runtimeStatus(alarms) {
|
|
15
|
+
const result = await scheduler.runtimeStatus(alarms);
|
|
16
|
+
const degraded = actionableDegradedAlarmIds(result.alarms);
|
|
17
|
+
const capabilities = result.capabilities;
|
|
18
|
+
return {
|
|
19
|
+
capabilities,
|
|
20
|
+
degradedAlarmIds: degraded,
|
|
21
|
+
message: capabilities.supported
|
|
22
|
+
? degraded.size ? `${degraded.size} alarm${degraded.size === 1 ? '' : 's'} need repair.` : 'Native scheduler ready.'
|
|
23
|
+
: capabilities.message
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
async activeAlarms() {
|
|
27
|
+
const clients = await connectActiveAlarms();
|
|
28
|
+
const statuses = await Promise.allSettled(clients.map(async (client) => ({ client, status: await client.status() })));
|
|
29
|
+
return statuses.flatMap(result => result.status === 'fulfilled' ? [activeView(result.value.client, result.value.status)] : []);
|
|
30
|
+
},
|
|
31
|
+
prepareTerminalAccess: () => prepareAlarmTerminalAccess(),
|
|
32
|
+
verifySetup: (alarm, settings, onUpdate) => verifyAlarmSetup(scheduler, alarm, settings, onUpdate)
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function actionableDegradedAlarmIds(items) {
|
|
36
|
+
const degraded = new Set();
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const latestScheduler = latest(item.health.filter(entry => entry.component === 'scheduler' && !entry.occurrenceAt));
|
|
39
|
+
const latestGuard = latest(item.health.filter(entry => entry.component === 'power' && !entry.occurrenceAt));
|
|
40
|
+
if (!item.native.healthy || latestScheduler?.healthy === false || latestGuard?.healthy === false)
|
|
41
|
+
degraded.add(item.alarmId);
|
|
42
|
+
}
|
|
43
|
+
return degraded;
|
|
44
|
+
}
|
|
45
|
+
function latest(entries) { return entries.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))[0]; }
|
|
46
|
+
/** Serializes every mutation for one alarm while preserving concurrency across alarms. */
|
|
47
|
+
export function serializeAlarmTuiService(service) {
|
|
48
|
+
const queues = new Map();
|
|
49
|
+
const enqueue = (alarmId, work) => {
|
|
50
|
+
const previous = queues.get(alarmId) ?? Promise.resolve();
|
|
51
|
+
const current = previous.catch(() => undefined).then(work);
|
|
52
|
+
queues.set(alarmId, current);
|
|
53
|
+
void current.finally(() => { if (queues.get(alarmId) === current)
|
|
54
|
+
queues.delete(alarmId); }).catch(() => undefined);
|
|
55
|
+
return current;
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
sync: alarm => enqueue(alarm.id, () => service.sync(alarm)),
|
|
59
|
+
syncAll: alarms => alarms.length === 0 ? service.syncAll([]) : Promise.all(alarms.map(alarm => enqueue(alarm.id, async () => {
|
|
60
|
+
try {
|
|
61
|
+
const [result] = await service.syncAll([alarm]);
|
|
62
|
+
return result ?? { id: alarm.id, occurrence: null, error: 'Alarm sync returned no result.' };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { id: alarm.id, occurrence: null, error: error instanceof Error ? error.message : String(error) };
|
|
66
|
+
}
|
|
67
|
+
}))),
|
|
68
|
+
remove: alarm => enqueue(alarm.id, () => service.remove(alarm)),
|
|
69
|
+
runtimeStatus: alarms => service.runtimeStatus(alarms),
|
|
70
|
+
activeAlarms: () => service.activeAlarms(),
|
|
71
|
+
prepareTerminalAccess: () => service.prepareTerminalAccess?.() ?? Promise.resolve(),
|
|
72
|
+
verifySetup: service.verifySetup ? (alarm, settings, onUpdate) => service.verifySetup(alarm, settings, onUpdate) : undefined
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function activeView(client, status) {
|
|
76
|
+
return {
|
|
77
|
+
key: `${status.alarmId}:${status.scheduledAt}`,
|
|
78
|
+
status,
|
|
79
|
+
dismiss: () => client.dismiss(),
|
|
80
|
+
snooze: minutes => client.snooze(minutes),
|
|
81
|
+
keepPlaying: () => client.keepPlaying(),
|
|
82
|
+
handoff: () => client.handoff()
|
|
83
|
+
};
|
|
84
|
+
}
|
package/dist/ui/app-state.js
CHANGED
|
@@ -158,6 +158,9 @@ export function activeTabForScreen(screen) {
|
|
|
158
158
|
if (screen === 'airplay-settings' || screen === 'airplay-code') {
|
|
159
159
|
return 'settings';
|
|
160
160
|
}
|
|
161
|
+
if (screen === 'alarms' || screen === 'alarm-editor' || screen === 'alarm-picker' || screen === 'alarm-ringing') {
|
|
162
|
+
return 'home';
|
|
163
|
+
}
|
|
161
164
|
return screen;
|
|
162
165
|
}
|
|
163
166
|
export function stationContextKeyForScreen(screen) {
|
package/dist/ui/ascii.js
CHANGED
|
@@ -97,6 +97,14 @@ function asciiGlyph(code) {
|
|
|
97
97
|
if (code === 0x2014 || code === 0x2013) {
|
|
98
98
|
return '-';
|
|
99
99
|
}
|
|
100
|
+
if (code === 0x203a || code === 0x2192)
|
|
101
|
+
return '>';
|
|
102
|
+
if (code === 0x2039 || code === 0x2190)
|
|
103
|
+
return '<';
|
|
104
|
+
if (code === 0x2191)
|
|
105
|
+
return '^';
|
|
106
|
+
if (code === 0x2193)
|
|
107
|
+
return 'v';
|
|
100
108
|
// Drop zero-width and combining marks entirely so they do not skew widths.
|
|
101
109
|
if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) {
|
|
102
110
|
return '';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Text } from 'ink';
|
|
4
|
+
import { displayWidth, sliceDisplay, truncate } from '../format.js';
|
|
5
|
+
const edgePauseMs = 1000;
|
|
6
|
+
const stepMs = 180;
|
|
7
|
+
export function AdaptiveMarquee({ text, width, active, reduceMotion }) {
|
|
8
|
+
const safeWidth = Math.max(0, width);
|
|
9
|
+
const maxOffset = Math.max(0, displayWidth(text) - safeWidth);
|
|
10
|
+
const animated = active && !reduceMotion && maxOffset > 0;
|
|
11
|
+
const [offset, setOffset] = useState(0);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
setOffset(0);
|
|
14
|
+
}, [active, reduceMotion, safeWidth, text]);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!animated)
|
|
17
|
+
return;
|
|
18
|
+
const delay = offset === 0 || offset >= maxOffset ? edgePauseMs : stepMs;
|
|
19
|
+
const timer = setTimeout(() => {
|
|
20
|
+
setOffset(current => current >= maxOffset ? 0 : current + 1);
|
|
21
|
+
}, delay);
|
|
22
|
+
return () => clearTimeout(timer);
|
|
23
|
+
}, [animated, maxOffset, offset]);
|
|
24
|
+
const visible = animated
|
|
25
|
+
? sliceDisplay(text, Math.min(offset, maxOffset), safeWidth)
|
|
26
|
+
: truncate(text, safeWidth);
|
|
27
|
+
return _jsx(Text, { children: visible });
|
|
28
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
4
4
|
import { textMuted, themeAccent } from '../theme.js';
|
|
5
5
|
import { Menu, Pointer } from './Menu.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
|
+
import { useDisplay } from '../display-context.js';
|
|
8
|
+
import { AdaptiveMarquee } from './AdaptiveMarquee.js';
|
|
7
9
|
export function StationList({ stations, selected, theme, favorites, pageSize, width, emptyTitle = 'No stations found.', emptyHint = 'Try another view or clear active filters.', showCount = true }) {
|
|
10
|
+
const { reduceMotion } = useDisplay();
|
|
8
11
|
if (stations.length === 0) {
|
|
9
12
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: emptyTitle }), _jsx(Text, { color: textMuted, children: emptyHint })] }));
|
|
10
13
|
}
|
|
@@ -14,12 +17,14 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
|
|
|
14
17
|
const metaWidth = Math.max(12, rowWidth - nameWidth - 6);
|
|
15
18
|
return (_jsxs(Box, { flexDirection: "column", children: [showCount ? (_jsxs(Text, { color: textMuted, children: [window.start + 1, "-", window.end, " of ", stations.length] })) : null, _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station, _index, active) => {
|
|
16
19
|
const favorite = favorites.has(`${station.provider}:${station.id}`);
|
|
17
|
-
|
|
20
|
+
if (width < 42) {
|
|
21
|
+
const compactNameWidth = Math.max(1, width - 2 - (favorite ? 2 : 0));
|
|
22
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }));
|
|
23
|
+
}
|
|
24
|
+
const stationNameWidth = favorite ? Math.max(1, nameWidth - 2) : nameWidth;
|
|
18
25
|
const titleWidth = nameWidth + 2;
|
|
19
|
-
const titleUsed = displayWidth(stationName) + (favorite ? 2 : 0);
|
|
20
|
-
const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
|
|
21
26
|
const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
|
|
22
27
|
const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
|
|
23
|
-
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children:
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: truncate(active ? selectedMetadata : standardMetadata, metaWidth) })] }));
|
|
24
29
|
} })] }));
|
|
25
30
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { updateAvailableForVersion } from '../../update-check.js';
|
|
4
|
+
import { displayWidth } from '../format.js';
|
|
5
|
+
import { appBackground, textDim, themeAccent } from '../theme.js';
|
|
6
|
+
export function availableUpdateLabel(currentVersion, updateCheck) {
|
|
7
|
+
return updateAvailableForVersion(updateCheck, currentVersion) && updateCheck?.latestVersion
|
|
8
|
+
? `v${updateCheck.latestVersion} available`
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
export function versionIndicatorWidth(currentVersion, updateCheck) {
|
|
12
|
+
const versionWidth = displayWidth(`v${currentVersion}`);
|
|
13
|
+
const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
|
|
14
|
+
return updateLabel ? displayWidth(updateLabel) + 3 + versionWidth : versionWidth;
|
|
15
|
+
}
|
|
16
|
+
export function VersionIndicator({ currentVersion, updateCheck, theme }) {
|
|
17
|
+
const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
|
|
18
|
+
return (_jsxs(Box, { flexShrink: 0, children: [updateLabel ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: appBackground, backgroundColor: themeAccent(theme), bold: true, children: ` ${updateLabel} ` }), _jsx(Text, { children: " " })] })) : null, _jsx(Text, { color: textDim, children: `v${currentVersion}` })] }));
|
|
19
|
+
}
|
|
@@ -14,8 +14,11 @@ const brailleBits = [
|
|
|
14
14
|
];
|
|
15
15
|
const mapCache = new Map();
|
|
16
16
|
export function buildCosmoWorldMap(width, height, markers = []) {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Layout owns minimum sizing. Keeping the renderer faithful to the requested
|
|
18
|
+
// viewport prevents clipped rows and mismatched mouse coordinates near
|
|
19
|
+
// responsive breakpoints.
|
|
20
|
+
const mapWidth = Math.max(1, Math.floor(width));
|
|
21
|
+
const mapHeight = Math.max(1, Math.floor(height));
|
|
19
22
|
const rows = buildCosmoMapRows(mapWidth, mapHeight);
|
|
20
23
|
const cells = rows.map(row => Array.from(row, char => ({
|
|
21
24
|
char: char === brailleBlank ? ' ' : char,
|
|
@@ -4,12 +4,23 @@ export function computeExploreMapLayout(width, height, pageSize = 1) {
|
|
|
4
4
|
// body margin below supplies the single gutter shared with the panel footer.
|
|
5
5
|
const headerRows = 2;
|
|
6
6
|
const bodyRows = Math.max(7, height - headerRows - 1);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
7
|
+
// Every non-micro Explore layout is side-by-side. Preserve the established
|
|
8
|
+
// wide/full proportions, while giving narrower full layouts a useful map and
|
|
9
|
+
// a deliberately compact station column.
|
|
10
|
+
const split = true;
|
|
11
|
+
const listPanelWidth = contentWidth >= 104
|
|
12
|
+
? Math.max(50, Math.min(74, Math.floor(contentWidth * 0.35)))
|
|
13
|
+
: Math.max(18, Math.floor(contentWidth * 0.35));
|
|
14
|
+
const mapPanelWidth = Math.max(1, contentWidth - listPanelWidth - 1);
|
|
15
|
+
const mapRows = Math.max(8, bodyRows - 2);
|
|
16
|
+
// A braille cell is two dots wide by four dots tall. With ordinary terminal
|
|
17
|
+
// cells roughly twice as tall as they are wide, a 2:1 world projection is
|
|
18
|
+
// represented by about four terminal columns per row. Use the panel as a
|
|
19
|
+
// viewport and center the map whenever filling it would stretch longitude.
|
|
20
|
+
const mapInnerWidth = Math.max(1, mapPanelWidth - 2);
|
|
21
|
+
const mapColumns = Math.max(1, Math.min(mapInnerWidth, mapRows * 4));
|
|
22
|
+
const mapOffsetX = Math.max(0, Math.floor((mapInnerWidth - mapColumns) / 2));
|
|
23
|
+
const listRows = bodyRows - 2;
|
|
13
24
|
const listPageSize = Math.max(1, Math.min(pageSize, listRows - 3));
|
|
14
25
|
return {
|
|
15
26
|
contentWidth,
|
|
@@ -20,6 +31,7 @@ export function computeExploreMapLayout(width, height, pageSize = 1) {
|
|
|
20
31
|
mapPanelWidth,
|
|
21
32
|
mapRows,
|
|
22
33
|
mapColumns,
|
|
34
|
+
mapOffsetX,
|
|
23
35
|
listRows,
|
|
24
36
|
listPageSize
|
|
25
37
|
};
|
package/dist/ui/format.js
CHANGED
|
@@ -46,6 +46,27 @@ export function displayWidth(value) {
|
|
|
46
46
|
export function padDisplayEnd(value, width) {
|
|
47
47
|
return value + ' '.repeat(Math.max(0, width - displayWidth(value)));
|
|
48
48
|
}
|
|
49
|
+
export function sliceDisplay(value, start, length) {
|
|
50
|
+
const safeStart = Math.max(0, Math.floor(start));
|
|
51
|
+
const safeLength = Math.max(0, Math.floor(length));
|
|
52
|
+
if (safeLength === 0)
|
|
53
|
+
return '';
|
|
54
|
+
let skippedWidth = 0;
|
|
55
|
+
let outputWidth = 0;
|
|
56
|
+
let result = '';
|
|
57
|
+
for (const grapheme of graphemes(value)) {
|
|
58
|
+
const graphemeWidth = displayWidth(grapheme);
|
|
59
|
+
if (skippedWidth + graphemeWidth <= safeStart) {
|
|
60
|
+
skippedWidth += graphemeWidth;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (outputWidth + graphemeWidth > safeLength)
|
|
64
|
+
break;
|
|
65
|
+
result += grapheme;
|
|
66
|
+
outputWidth += graphemeWidth;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
49
70
|
export function removeLastGrapheme(value) {
|
|
50
71
|
const parts = graphemes(value);
|
|
51
72
|
parts.pop();
|
package/dist/ui/help-content.js
CHANGED
|
@@ -5,7 +5,7 @@ export const keyHelpSections = [
|
|
|
5
5
|
{ keys: '↑/↓ or n/p', description: 'Move selection' },
|
|
6
6
|
{ keys: '←/→ or Tab', description: 'Switch tabs' },
|
|
7
7
|
{ keys: '[ / ]', description: 'Page selection by 10' },
|
|
8
|
-
{ keys: '1-
|
|
8
|
+
{ keys: '1-9', description: 'Jump to an Overview option' },
|
|
9
9
|
{ keys: 'Enter', description: 'Open / tune the selection' },
|
|
10
10
|
{ keys: 'b or Esc', description: 'Back to home' },
|
|
11
11
|
{ keys: ': ', description: 'Command palette' },
|
|
@@ -24,7 +24,19 @@ export const keyHelpSections = [
|
|
|
24
24
|
{ keys: 'O', description: 'Open station homepage' },
|
|
25
25
|
{ keys: 'y', description: 'Copy stream URL' },
|
|
26
26
|
{ keys: 's', description: 'Sleep timer (Now Playing)' },
|
|
27
|
-
{ keys: 'd', description: 'Diagnostics + recent tracks (Now Playing)' }
|
|
27
|
+
{ keys: 'd', description: 'Diagnostics + recent tracks (Now Playing)' },
|
|
28
|
+
{ keys: 'a', description: 'Schedule the selected / playing station' },
|
|
29
|
+
{ keys: '!', description: 'Open controls for a ringing alarm from any screen' }
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: 'Alarms',
|
|
34
|
+
entries: [
|
|
35
|
+
{ keys: 'Enter / n / e', description: 'Choose Create alarm, use the new shortcut, or edit the selected alarm' },
|
|
36
|
+
{ keys: 'space', description: 'Enable / disable selected alarm' },
|
|
37
|
+
{ keys: 'x twice', description: 'Confirm alarm deletion' },
|
|
38
|
+
{ keys: 't / r / g', description: 'Test-tune / repair / toggle Alarm Guard' },
|
|
39
|
+
{ keys: 'd / s / k', description: 'Dismiss / snooze / keep playing while ringing' }
|
|
28
40
|
]
|
|
29
41
|
},
|
|
30
42
|
{
|
package/dist/ui/layout.js
CHANGED
|
@@ -13,7 +13,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
|
|
|
13
13
|
const desiredFooterRows = mode === 'full'
|
|
14
14
|
? clamp(Math.round(footerRows), 2, 4)
|
|
15
15
|
: mode === 'compact'
|
|
16
|
-
? (safeRows >= 14 ?
|
|
16
|
+
? (safeRows >= 14 ? 3 : 2)
|
|
17
17
|
: 1;
|
|
18
18
|
const reservedFooterRows = Math.min(desiredFooterRows, Math.max(0, safeRows - topRows - 1));
|
|
19
19
|
const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
|