@ciphore/radiocli 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { rightToLeftPattern, strongDirectionPattern } from './bidi-ranges.js';
|
|
3
|
+
import { displayWidth } from './format.js';
|
|
4
|
+
// Ink writes complete VT sequences. Keep OSC payloads (titles, hyperlinks) out
|
|
5
|
+
// of text analysis, and treat cursor controls as boundaries between text runs.
|
|
6
|
+
const controls = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[78@-_])|[\u0000-\u001F\u007F]/gu;
|
|
7
|
+
const sgr = /^\u001B\[[\d;:]*m$/u;
|
|
8
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
9
|
+
/**
|
|
10
|
+
* Yoga's boxes become a single terminal row, not independently positioned
|
|
11
|
+
* fields. Guard fixed fields and borders against terminal-side shaping in
|
|
12
|
+
* mixed RTL rows by explicitly positioning the columns around each RTL run.
|
|
13
|
+
* Apple Terminal's xterm profile also gets Unicode isolates to contain stored
|
|
14
|
+
* row reordering independently of cursor reports. Keep those opt-in: older
|
|
15
|
+
* Linux/BSD consoles may print unsupported format marks.
|
|
16
|
+
* Ordinary LTR output stays byte-for-byte unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Apply only to Ink's interactive stdout, after its cell layout/tokenization:
|
|
19
|
+
* inserting zero-width bidi marks into Text would itself consume Ink cells.
|
|
20
|
+
*/
|
|
21
|
+
export function rtlSafeOutput(stdout, env = process.env) {
|
|
22
|
+
const isolate = env.TERM_PROGRAM === 'Apple_Terminal' && /^xterm(?:-|$)/i.test(env.TERM ?? '');
|
|
23
|
+
let column = 0;
|
|
24
|
+
let savedColumn;
|
|
25
|
+
const position = (control) => {
|
|
26
|
+
if (control === '\n' || control === '\r')
|
|
27
|
+
column = 0;
|
|
28
|
+
else if (control === '\b' && column !== undefined)
|
|
29
|
+
column = Math.max(0, column - 1);
|
|
30
|
+
else if (control === '\t' && column !== undefined)
|
|
31
|
+
column = (Math.floor(column / 8) + 1) * 8;
|
|
32
|
+
else if (control === '\u001B7' || control === '\u001B[s')
|
|
33
|
+
savedColumn = column;
|
|
34
|
+
else if (control === '\u001B8' || control === '\u001B[u')
|
|
35
|
+
column = savedColumn;
|
|
36
|
+
else {
|
|
37
|
+
const csi = /^\u001B\[([\d;]*)([G`CDaHEFf])$/u.exec(control);
|
|
38
|
+
if (!csi)
|
|
39
|
+
return;
|
|
40
|
+
const parameters = csi[1].split(';').map(value => Number(value) || 1);
|
|
41
|
+
const command = csi[2];
|
|
42
|
+
if (command === 'G' || command === '`')
|
|
43
|
+
column = parameters[0] - 1;
|
|
44
|
+
else if (command === 'H' || command === 'f')
|
|
45
|
+
column = (parameters[1] ?? 1) - 1;
|
|
46
|
+
else if (command === 'E' || command === 'F')
|
|
47
|
+
column = 0;
|
|
48
|
+
else if (column !== undefined)
|
|
49
|
+
column = Math.max(0, column + (command === 'D' ? -1 : 1) * parameters[0]);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const text = (span, endsRow = false) => {
|
|
53
|
+
const plain = stripVTControlCharacters(span);
|
|
54
|
+
const hasRtl = rightToLeftPattern.test(plain);
|
|
55
|
+
// Animation frames contain many art-only rows. Their next newline resets
|
|
56
|
+
// the column, so do not remeasure thousands of unchanged visualizer cells.
|
|
57
|
+
if (!hasRtl && endsRow)
|
|
58
|
+
return span;
|
|
59
|
+
const startColumn = column;
|
|
60
|
+
if (column !== undefined)
|
|
61
|
+
column += displayWidth(plain);
|
|
62
|
+
if (startColumn === undefined || !hasRtl)
|
|
63
|
+
return span;
|
|
64
|
+
const runs = [];
|
|
65
|
+
let first;
|
|
66
|
+
let last;
|
|
67
|
+
let offset = startColumn;
|
|
68
|
+
const finish = () => {
|
|
69
|
+
if (first && last)
|
|
70
|
+
runs.push({ first, last });
|
|
71
|
+
first = undefined;
|
|
72
|
+
last = undefined;
|
|
73
|
+
};
|
|
74
|
+
// Segment the complete visible text: SGR can occur inside a combining or
|
|
75
|
+
// emoji cluster. Map its UTF-16 edges back to the original styled span.
|
|
76
|
+
const indices = [];
|
|
77
|
+
let from = 0;
|
|
78
|
+
for (const match of span.matchAll(new RegExp(controls))) {
|
|
79
|
+
for (let index = from; index < match.index; index++)
|
|
80
|
+
indices.push(index);
|
|
81
|
+
from = match.index + match[0].length;
|
|
82
|
+
}
|
|
83
|
+
for (let index = from; index < span.length; index++)
|
|
84
|
+
indices.push(index);
|
|
85
|
+
// Neutral characters between RTL words belong to the same run, preserving
|
|
86
|
+
// Arabic word order without absorbing trailing padding or fixed fields.
|
|
87
|
+
for (const part of segmenter.segment(plain)) {
|
|
88
|
+
const width = displayWidth(part.segment);
|
|
89
|
+
const strong = strongDirectionPattern.exec(part.segment)?.[0];
|
|
90
|
+
if (strong && rightToLeftPattern.test(strong)) {
|
|
91
|
+
first ??= { index: indices[part.index], column: offset };
|
|
92
|
+
last = { index: indices[part.index + part.segment.length - 1] + 1, column: offset + width };
|
|
93
|
+
}
|
|
94
|
+
else if (strong)
|
|
95
|
+
finish();
|
|
96
|
+
offset += width;
|
|
97
|
+
}
|
|
98
|
+
finish();
|
|
99
|
+
const at = (value) => `\u001B[${value + 1}G`;
|
|
100
|
+
let result = '';
|
|
101
|
+
from = 0;
|
|
102
|
+
for (const run of runs) {
|
|
103
|
+
result += span.slice(from, run.first.index) + at(run.first.column)
|
|
104
|
+
+ (isolate ? '\u2067' : '') + span.slice(run.first.index, run.last.index)
|
|
105
|
+
+ (isolate ? '\u2069' : '') + at(run.last.column);
|
|
106
|
+
from = run.last.index;
|
|
107
|
+
}
|
|
108
|
+
return result + span.slice(from);
|
|
109
|
+
};
|
|
110
|
+
const transform = (value) => {
|
|
111
|
+
let result = '';
|
|
112
|
+
let from = 0;
|
|
113
|
+
for (const match of value.matchAll(new RegExp(controls))) {
|
|
114
|
+
if (sgr.test(match[0]))
|
|
115
|
+
continue;
|
|
116
|
+
result += text(value.slice(from, match.index), match[0] === '\n' || match[0] === '\r') + match[0];
|
|
117
|
+
position(match[0]);
|
|
118
|
+
from = match.index + match[0].length;
|
|
119
|
+
}
|
|
120
|
+
return result + text(value.slice(from));
|
|
121
|
+
};
|
|
122
|
+
const write = (chunk, ...args) => {
|
|
123
|
+
// Ink frames are strings. Preserve other writes and their encoding/callback
|
|
124
|
+
// contract; resume anchoring only after a known cursor position or newline.
|
|
125
|
+
if (typeof chunk !== 'string')
|
|
126
|
+
column = undefined;
|
|
127
|
+
return Reflect.apply(stdout.write, stdout, [typeof chunk === 'string' ? transform(chunk) : chunk, ...args]);
|
|
128
|
+
};
|
|
129
|
+
return new Proxy(stdout, {
|
|
130
|
+
get(target, key) {
|
|
131
|
+
if (key === 'write')
|
|
132
|
+
return write;
|
|
133
|
+
const value = Reflect.get(target, key, target);
|
|
134
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
package/dist/ui/screen-items.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const homeItems = [
|
|
2
2
|
{ screen: 'now-playing', label: 'Playing', detail: 'Receiver display and controls' },
|
|
3
|
-
{ screen: 'library', label: 'Library', detail: 'Favorites,
|
|
3
|
+
{ screen: 'library', label: 'Library', detail: 'Favorites, imported streams, recent stations' },
|
|
4
4
|
{ screen: 'explore', label: 'Explore', detail: 'Move a map cursor through geotagged stations' },
|
|
5
5
|
{ screen: 'search', label: 'Search', detail: 'Find stations by name, genre, language, place' },
|
|
6
6
|
{ screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
|
|
@@ -54,7 +54,7 @@ export const settingsGroups = [
|
|
|
54
54
|
description: 'Back up or restore preferences and saved stations',
|
|
55
55
|
items: [
|
|
56
56
|
'Export preferences and library',
|
|
57
|
-
'
|
|
57
|
+
'Restore preferences and library'
|
|
58
58
|
]
|
|
59
59
|
},
|
|
60
60
|
{
|
|
@@ -91,11 +91,17 @@ export const settingsGroups = [
|
|
|
91
91
|
];
|
|
92
92
|
export const settingsItems = settingsGroups.flatMap(group => group.items);
|
|
93
93
|
export const settingsRootItems = settingsGroups.map(group => group.label);
|
|
94
|
-
export function
|
|
95
|
-
return settingsGroups.
|
|
94
|
+
export function settingsGroupsForAirPlay(visible = true) {
|
|
95
|
+
return settingsGroups.map(group => ({ ...group,
|
|
96
|
+
description: !visible && group.id === 'playback' ? 'Output, volume, and startup behavior' : group.description,
|
|
97
|
+
items: group.items.filter(item => visible || item !== 'AirPlay receiver')
|
|
98
|
+
}));
|
|
96
99
|
}
|
|
97
|
-
export function
|
|
98
|
-
return
|
|
100
|
+
export function settingsGroup(page, airPlayVisible = true) {
|
|
101
|
+
return settingsGroupsForAirPlay(airPlayVisible).find(group => group.id === page);
|
|
102
|
+
}
|
|
103
|
+
export function settingsItemsForPage(page, airPlayVisible = true) {
|
|
104
|
+
return page === 'root' ? settingsRootItems : settingsGroup(page, airPlayVisible)?.items ?? [];
|
|
99
105
|
}
|
|
100
106
|
export function settingsPageForRootItem(item) {
|
|
101
107
|
return settingsGroups.find(group => group.label === item)?.id;
|
package/dist/ui/screen-meta.js
CHANGED
|
@@ -12,7 +12,7 @@ export const primaryScreens = [
|
|
|
12
12
|
export const screenMetadata = {
|
|
13
13
|
home: { tabLabel: 'Overview', title: 'Overview', description: 'Your listening at a glance' },
|
|
14
14
|
'now-playing': { tabLabel: 'Playing', title: 'Now playing', description: 'Receiver and playback controls' },
|
|
15
|
-
library: { tabLabel: 'Library', title: 'Library', description: 'Favorites,
|
|
15
|
+
library: { tabLabel: 'Library', title: 'Library', description: 'Favorites, imported streams, and recents' },
|
|
16
16
|
explore: { tabLabel: 'Explore', title: 'Explore', description: 'Discover stations on the world map' },
|
|
17
17
|
search: { tabLabel: 'Search', title: 'Search', description: 'Find stations, genres, languages, or places' },
|
|
18
18
|
countries: { tabLabel: 'Countries', title: 'Countries', description: 'Browse stations by country' },
|
|
@@ -2,16 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
4
4
|
import { truncate } from '../format.js';
|
|
5
|
+
import { useDisplay } from '../display-context.js';
|
|
6
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
5
7
|
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
6
8
|
export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
|
|
9
|
+
const { ascii } = useDisplay();
|
|
10
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
7
11
|
const accent = themeAccent(theme);
|
|
8
12
|
const lineWidth = Math.max(24, width - 4);
|
|
9
13
|
const promptActive = isAirPlayCodePromptActive(playback);
|
|
10
14
|
const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
|
|
11
15
|
const masked = code.length > 0 ? '*'.repeat(code.length) : '';
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: a(truncate(receiverName, Math.max(8, lineWidth - 10))) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
|
|
13
17
|
? 'The receiver is asking for a code now.'
|
|
14
|
-
: 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children:
|
|
18
|
+
: 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: a('Enter submits · Backspace edits · Esc returns to AirPlay') })] })] }));
|
|
15
19
|
}
|
|
16
20
|
export function isAirPlayCodePromptActive(playback) {
|
|
17
21
|
return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
|
|
@@ -7,20 +7,24 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
|
7
7
|
import { truncate } from '../format.js';
|
|
8
8
|
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
9
9
|
import { visibleWindow } from '../list-window.js';
|
|
10
|
-
|
|
10
|
+
import { useDisplay } from '../display-context.js';
|
|
11
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
12
|
+
export function AirPlaySettingsScreen({ platform = process.platform, selected, settings, backends, devices, theme, width, height }) {
|
|
13
|
+
const { ascii } = useDisplay();
|
|
14
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
11
15
|
const accent = themeAccent(theme);
|
|
12
|
-
const availability = airPlayAvailability(backends, devices);
|
|
16
|
+
const availability = airPlayAvailability(backends, devices, platform);
|
|
13
17
|
const selectedDevice = selectedAirPlayDevice(settings, devices);
|
|
14
18
|
const selectedMissing = Boolean(settings.preferredAirPlayDevice && !selectedDevice);
|
|
15
19
|
const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
|
|
16
20
|
const lineWidth = Math.max(24, width - 4);
|
|
17
21
|
const preferredOutput = audioOutputLabel(settings.preferredBackend);
|
|
18
22
|
const deviceWindow = visibleWindow(devices, selected, height ? Math.max(1, height - 9) : Math.max(1, devices.length));
|
|
19
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }),
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), a(' · Streaming: '), _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), a(' · Receivers: '), _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: a(truncate(availability.detail, lineWidth)) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: a(truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10))) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", a(truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31)))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: deviceWindow.items, selected: selected - deviceWindow.start, keyFor: device => device.id, render: (device, _index, active) => {
|
|
20
24
|
const isSelected = device.id === settings.preferredAirPlayDevice;
|
|
21
25
|
const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
|
|
22
26
|
const selectedLabel = isSelected ? ' selected' : '';
|
|
23
|
-
const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
|
|
24
|
-
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null,
|
|
25
|
-
} })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
|
|
27
|
+
const detail = truncate(airPlayDeviceDetail(device, platform), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(name) }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsx(Text, { color: textMuted, children: a(` · ${detail}`) })] }));
|
|
29
|
+
} })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: a(canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers') })] })] }));
|
|
26
30
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
2
3
|
import { Box, Text } from 'ink';
|
|
3
4
|
import { draftNextOccurrence } from '../alarm-editor.js';
|
|
4
|
-
import {
|
|
5
|
+
import { AlarmOccurrenceCache } from '../../alarms/occurrence-cache.js';
|
|
5
6
|
import { stationKey } from '../../storage/store.js';
|
|
6
7
|
import { visibleWindow } from '../list-window.js';
|
|
7
8
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
@@ -15,10 +16,12 @@ export function AlarmsScreen({ alarms, selected, runtime, verification, deleting
|
|
|
15
16
|
const { ascii, reduceMotion } = useDisplay();
|
|
16
17
|
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
17
18
|
const mode = requestedMode ?? (width < 34 ? 'micro' : width < 68 ? 'compact' : 'full');
|
|
19
|
+
const occurrenceCache = useMemo(() => new AlarmOccurrenceCache(), []);
|
|
18
20
|
if (verification)
|
|
19
21
|
return _jsx(AlarmVerificationScreen, { report: verification, theme: theme, width: width, height: height });
|
|
20
22
|
const status = schedulerStatus(runtime, mode);
|
|
21
|
-
const
|
|
23
|
+
const now = new Date();
|
|
24
|
+
const next = alarms.map(alarm => ({ alarm, occurrence: occurrenceCache.next(alarm, now) })).filter(item => item.occurrence).sort((a, b) => a.occurrence.getTime() - b.occurrence.getTime())[0];
|
|
22
25
|
const noticeRows = height >= 11 ? 2 : 1;
|
|
23
26
|
const rows = Math.max(1, height - 5 - noticeRows);
|
|
24
27
|
const items = [{ kind: 'create' }, ...alarms.map(alarm => ({ kind: 'alarm', alarm })), { kind: 'verify' }];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
+
import { formatExploreCursor } from '../app-state.js';
|
|
4
5
|
import { StationList } from '../components/StationList.js';
|
|
5
6
|
import { buildCosmoWorldMap } from '../cosmo-world-map.js';
|
|
6
7
|
import { computeExploreMapLayout } from '../explore-map-layout.js';
|
|
@@ -14,7 +15,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, er
|
|
|
14
15
|
const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, mapOffsetX, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
|
|
15
16
|
const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
|
|
16
17
|
const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
|
|
17
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", "aria-label": `World map cursor: ${formatExploreCursor(cursor)}`, children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
|
|
18
19
|
? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…')
|
|
19
20
|
: error
|
|
20
21
|
? truncate(ascii ? toAsciiSafe(error) : error, Math.max(1, listPanelWidth - 2))
|
|
@@ -2,10 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { textMuted, themeAccent } from '../theme.js';
|
|
4
4
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
5
|
-
import {
|
|
5
|
+
import { commandHelpForAirPlay, keyHelpSections } from '../help-content.js';
|
|
6
6
|
import { padDisplayEnd, truncate } from '../format.js';
|
|
7
7
|
import { visibleWindow } from '../list-window.js';
|
|
8
|
-
|
|
8
|
+
import { useDisplay } from '../display-context.js';
|
|
9
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
10
|
+
export function HelpScreen({ theme, width, height, selected, airPlaySupported = true }) {
|
|
11
|
+
const { ascii } = useDisplay();
|
|
12
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
9
13
|
const accent = themeAccent(theme);
|
|
10
14
|
const keyColumnWidth = 16;
|
|
11
15
|
const lineWidth = Math.max(28, width - 2);
|
|
@@ -15,12 +19,12 @@ export function HelpScreen({ theme, width, height, selected }) {
|
|
|
15
19
|
...section.entries.map(entry => ({ key: `${section.title}-${entry.keys}`, ...entry }))
|
|
16
20
|
]),
|
|
17
21
|
{ key: 'commands', heading: 'Commands (press : then type)' },
|
|
18
|
-
...
|
|
22
|
+
...commandHelpForAirPlay(airPlaySupported).map(command => ({
|
|
19
23
|
key: `command-${command.name}`,
|
|
20
24
|
keys: `:${command.name}${command.args ? ` ${command.args}` : ''}`,
|
|
21
25
|
description: command.description
|
|
22
26
|
}))
|
|
23
27
|
];
|
|
24
28
|
const window = visibleWindow(rows, selected, Math.max(3, height - 4));
|
|
25
|
-
return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth) }), _jsx(Text, { color: textMuted, children: truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)) })] }, row.key))) }),
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)) }), _jsx(Text, { color: textMuted, children: a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))) })] }, row.key))) }), _jsx(Text, { color: textMuted, children: a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`) })] }));
|
|
26
30
|
}
|
|
@@ -5,7 +5,11 @@ import { Menu, Pointer } from '../components/Menu.js';
|
|
|
5
5
|
import { textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { homeItems } from '../screen-items.js';
|
|
7
7
|
import { displayWidth } from '../format.js';
|
|
8
|
+
import { useDisplay } from '../display-context.js';
|
|
9
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
8
10
|
export function HomeScreen({ selected, theme, library }) {
|
|
11
|
+
const { ascii } = useDisplay();
|
|
12
|
+
const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`;
|
|
9
13
|
const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2;
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children:
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textMuted, children: ascii ? toAsciiSafe(summary) : summary }) })] }));
|
|
11
15
|
}
|
|
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
|
|
|
18
18
|
const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
|
|
19
19
|
const topWidth = mode === 'full' ? 50 : contentWidth;
|
|
20
20
|
const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", "aria-hidden": true, children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
|
|
22
22
|
}
|
|
23
23
|
function MapLine({ row, theme }) {
|
|
24
24
|
const { ascii } = useDisplay();
|
|
@@ -14,7 +14,7 @@ const receiverPaddingX = 3;
|
|
|
14
14
|
const receiverPaddingY = 1;
|
|
15
15
|
export function NowPlayingScreen({ station, playback, metadata, theme, favorite, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
|
|
16
16
|
const pulse = useReceiverPulse();
|
|
17
|
-
const { panel: panelBackground, ascii } = useDisplay();
|
|
17
|
+
const { panel: panelBackground, ascii, screenReader } = useDisplay();
|
|
18
18
|
// In ASCII mode route every rendered string through the glyph mapper so no
|
|
19
19
|
// braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
|
|
20
20
|
const a = (value) => (ascii ? toAsciiSafe(value) : value);
|
|
@@ -28,7 +28,7 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
28
28
|
// visualization equally separated from the header and track title.
|
|
29
29
|
const visualizerGutterRows = 1;
|
|
30
30
|
const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
|
|
31
|
-
const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
|
|
31
|
+
const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
|
|
32
32
|
const receiverState = playback.state.toUpperCase();
|
|
33
33
|
const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
|
|
34
34
|
const stationIdentity = receiverStationIdentity(station, identityWidth);
|
|
@@ -42,9 +42,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
|
|
|
42
42
|
: infoFallback;
|
|
43
43
|
const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
|
|
44
44
|
const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, children: receiverState })] }), _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
|
|
46
46
|
? renderSegments(row.segments)
|
|
47
|
-
: row.text }, index))) }), _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
47
|
+
: row.text }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
|
|
48
48
|
}
|
|
49
49
|
function asciifyVisualRow(row) {
|
|
50
50
|
return {
|
|
@@ -5,10 +5,12 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
|
5
5
|
import { panelBorder, textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
7
7
|
import { truncate } from '../format.js';
|
|
8
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
8
9
|
export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
|
|
9
10
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
11
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
10
12
|
const contentWidth = Math.max(40, width);
|
|
11
13
|
const inputWidth = Math.max(34, Math.min(contentWidth, 96));
|
|
12
14
|
const inputTextWidth = Math.max(8, inputWidth - 8);
|
|
13
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children:
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: a(editing ? '› ' : ' ') }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)) })] }), loading ? _jsx(Text, { color: textMuted, children: a('Searching station directories…') }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
|
|
14
16
|
}
|
|
@@ -4,21 +4,25 @@ import { defaultAgentControlSettings } from '../../types.js';
|
|
|
4
4
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
5
5
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
6
6
|
import { truncate } from '../format.js';
|
|
7
|
-
import { settingsGroup,
|
|
7
|
+
import { settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage } from '../screen-items.js';
|
|
8
8
|
import { textMuted, themeAccent } from '../theme.js';
|
|
9
9
|
import { playbackBackendCapabilities } from '../../player/backend-install.js';
|
|
10
10
|
import { airPlayReceiverSettingValue } from '../airplay-settings.js';
|
|
11
11
|
import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
|
|
12
12
|
import { updateStatusText } from '../../update-check.js';
|
|
13
|
-
|
|
13
|
+
import { useDisplay } from '../display-context.js';
|
|
14
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
15
|
+
export function SettingsScreen({ platform = process.platform, page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height, airPlaySupported = true }) {
|
|
16
|
+
const { ascii } = useDisplay();
|
|
17
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
14
18
|
const accent = themeAccent(theme);
|
|
15
19
|
const lineWidth = Math.max(32, width - 4);
|
|
16
|
-
const group = settingsGroup(page);
|
|
17
|
-
const pageItems = settingsItemsForPage(page);
|
|
20
|
+
const group = settingsGroup(page, airPlaySupported);
|
|
21
|
+
const pageItems = settingsItemsForPage(page, airPlaySupported);
|
|
18
22
|
const menuWindow = settingsMenuWindow(pageItems, selected, height);
|
|
19
23
|
const selectedItem = pageItems[selected];
|
|
20
24
|
const detail = page === 'root' && selectedItem !== 'Check for updates'
|
|
21
|
-
? settingsGroupsDescription(selectedItem)
|
|
25
|
+
? settingsGroupsDescription(selectedItem, airPlaySupported)
|
|
22
26
|
: settingDetail({
|
|
23
27
|
item: selectedItem,
|
|
24
28
|
appVersion,
|
|
@@ -33,12 +37,12 @@ export function SettingsScreen({ page = 'root', selected, settings, appVersion,
|
|
|
33
37
|
const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
|
|
34
38
|
const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
|
|
35
39
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: group ? `Settings › ${group.label}` : 'Choose a category', width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: item => item, render: (item, _index, active) => {
|
|
36
|
-
const label = settingLabel(item, updateCheck, appVersion);
|
|
40
|
+
const label = settingLabel(item, updateCheck, appVersion, airPlaySupported);
|
|
37
41
|
const value = page === 'root'
|
|
38
|
-
? settingsRootValue(item)
|
|
39
|
-
: settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
|
|
40
|
-
return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] }));
|
|
41
|
-
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
|
|
42
|
+
? settingsRootValue(item, airPlaySupported)
|
|
43
|
+
: settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion, platform);
|
|
44
|
+
return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(truncate(label, labelWidth)) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: a(truncate(value ?? '', valueWidth)) }) })] }));
|
|
45
|
+
} }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: a(truncate(detail, lineWidth)) })] })] }));
|
|
42
46
|
}
|
|
43
47
|
function settingsMenuWindow(items, selected, height) {
|
|
44
48
|
const reservedRows = 6;
|
|
@@ -52,17 +56,17 @@ function settingsMenuWindow(items, selected, height) {
|
|
|
52
56
|
selectedOffset: clampedSelected - pageStart
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
|
-
function settingsRootValue(item) {
|
|
56
|
-
const group = settingsGroupByLabel(item);
|
|
59
|
+
function settingsRootValue(item, airPlayVisible) {
|
|
60
|
+
const group = settingsGroupByLabel(item, airPlayVisible);
|
|
57
61
|
return group ? `${group.items.length} settings ›` : undefined;
|
|
58
62
|
}
|
|
59
|
-
function settingsGroupByLabel(label) {
|
|
60
|
-
return
|
|
63
|
+
function settingsGroupByLabel(label, airPlayVisible) {
|
|
64
|
+
return settingsGroupsForAirPlay(airPlayVisible).find(group => group.label === label);
|
|
61
65
|
}
|
|
62
|
-
function settingsGroupsDescription(label) {
|
|
63
|
-
return label ? settingsGroupByLabel(label)?.description ?? 'Press Enter to open this category.' : '';
|
|
66
|
+
function settingsGroupsDescription(label, airPlayVisible) {
|
|
67
|
+
return label ? settingsGroupByLabel(label, airPlayVisible)?.description ?? 'Press Enter to open this category.' : '';
|
|
64
68
|
}
|
|
65
|
-
export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion) {
|
|
69
|
+
export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion, platform = process.platform) {
|
|
66
70
|
switch (item) {
|
|
67
71
|
case 'Cycle display color':
|
|
68
72
|
return settings.theme;
|
|
@@ -77,7 +81,7 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
|
|
|
77
81
|
case 'Audio output':
|
|
78
82
|
return audioOutputSettingValue(settings, diagnostics, backends);
|
|
79
83
|
case 'AirPlay receiver':
|
|
80
|
-
return airPlayReceiverSettingValue(settings, airPlayDevices, backends);
|
|
84
|
+
return airPlayReceiverSettingValue(settings, airPlayDevices, backends, platform);
|
|
81
85
|
case 'Mute or unmute':
|
|
82
86
|
if (!playbackBackendCapabilities(diagnostics.backend).supportsMute) {
|
|
83
87
|
return 'requires mpv';
|
|
@@ -111,13 +115,16 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
|
|
|
111
115
|
return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
|
|
112
116
|
case 'Export preferences and library':
|
|
113
117
|
return 'JSON backup';
|
|
114
|
-
case '
|
|
118
|
+
case 'Restore preferences and library':
|
|
115
119
|
return 'restore JSON backup';
|
|
116
120
|
default:
|
|
117
121
|
return undefined;
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
|
-
export function settingLabel(item, updateCheck, currentVersion) {
|
|
124
|
+
export function settingLabel(item, updateCheck, currentVersion, airPlaySupported = true) {
|
|
125
|
+
if (item === 'AirPlay receiver' && !airPlaySupported) {
|
|
126
|
+
return 'AirPlay receiver (unavailable)';
|
|
127
|
+
}
|
|
121
128
|
if (item === 'Check for updates') {
|
|
122
129
|
return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
|
|
123
130
|
}
|
|
@@ -145,7 +152,7 @@ function settingDetail(input) {
|
|
|
145
152
|
if (item === 'Automatically check for updates') {
|
|
146
153
|
return 'When enabled, RadioCLI checks the registry once whenever the app launches.';
|
|
147
154
|
}
|
|
148
|
-
if (item === 'Export preferences and library' || item === '
|
|
155
|
+
if (item === 'Export preferences and library' || item === 'Restore preferences and library') {
|
|
149
156
|
return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
|
|
150
157
|
}
|
|
151
158
|
if (item === 'Share favorite votes with Radio Browser') {
|
|
@@ -6,6 +6,7 @@ import { panelBorder, textMuted, themeAccent, themeContributionColors } from '..
|
|
|
6
6
|
import { panelBorderStyle, useDisplay } from '../display-context.js';
|
|
7
7
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
8
8
|
import { truncate } from '../format.js';
|
|
9
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
9
10
|
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
10
11
|
const dayLabelWidth = 5;
|
|
11
12
|
// Two terminal columns are approximately one row high in common monospace
|
|
@@ -15,6 +16,7 @@ const heatmapCell = ' ';
|
|
|
15
16
|
const heatmapGap = '';
|
|
16
17
|
export function StatsScreen({ library, theme, width, height }) {
|
|
17
18
|
const { panel: panelBackground, ascii } = useDisplay();
|
|
19
|
+
const a = (value) => ascii ? toAsciiSafe(value) : value;
|
|
18
20
|
const stats = computeListeningStats(library.activity.sessions);
|
|
19
21
|
const contentWidth = Math.max(20, width - 4);
|
|
20
22
|
const graph = buildContributionGraph(stats.days, contentWidth, height);
|
|
@@ -24,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
|
|
|
24
26
|
const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
|
|
25
27
|
const favoriteWidth = Math.max(8, metricWidth - 18);
|
|
26
28
|
const compact = height < 30;
|
|
27
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children:
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: a(`Activity — ${graph.year}`) }) }), _jsxs(Box, { flexDirection: "column", "aria-label": `Activity graph: ${stats.activeDays} active days in ${graph.year}`, children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), _jsxs(Text, { children: [_jsx(Text, { color: textMuted, children: "Active days: " }), _jsxs(Text, { color: themeAccent(theme), children: [stats.activeDays, "/", stats.totalTrackedDays] })] })] }), _jsxs(Box, { marginTop: 1, "aria-hidden": true, children: [_jsx(Text, { color: textMuted, children: a('Less · ') }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
|
|
28
30
|
}
|
|
29
31
|
function renderContributionCells(cells, graphColors, cellGap) {
|
|
30
32
|
const runs = [];
|