@ciphore/radiocli 0.1.0
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 +47 -0
- package/CODE_OF_CONDUCT.md +13 -0
- package/CONTRIBUTING.md +56 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/SECURITY.md +31 -0
- package/dist/activity/stats.js +122 -0
- package/dist/cli.js +143 -0
- package/dist/player/backend-install.js +122 -0
- package/dist/player/command.js +8 -0
- package/dist/player/player-controller.js +494 -0
- package/dist/playlists/playlist.js +169 -0
- package/dist/providers/cache.js +92 -0
- package/dist/providers/provider-manager.js +50 -0
- package/dist/providers/radio-browser.js +412 -0
- package/dist/providers/radio-garden.js +87 -0
- package/dist/storage/store.js +369 -0
- package/dist/types.js +55 -0
- package/dist/ui/App.js +770 -0
- package/dist/ui/AppContent.js +45 -0
- package/dist/ui/app-state.js +250 -0
- package/dist/ui/components/Logo.js +9 -0
- package/dist/ui/components/Menu.js +23 -0
- package/dist/ui/components/ScreenHeader.js +15 -0
- package/dist/ui/components/StationList.js +23 -0
- package/dist/ui/components/TopTabs.js +82 -0
- package/dist/ui/cosmo-land-data.js +4 -0
- package/dist/ui/cosmo-world-map.js +156 -0
- package/dist/ui/explore-map-layout.js +24 -0
- package/dist/ui/format.js +22 -0
- package/dist/ui/layout.js +27 -0
- package/dist/ui/list-window.js +8 -0
- package/dist/ui/page-footer.js +45 -0
- package/dist/ui/playback-footer.js +48 -0
- package/dist/ui/screen-items.js +26 -0
- package/dist/ui/screens/CountriesScreen.js +10 -0
- package/dist/ui/screens/ExploreScreen.js +44 -0
- package/dist/ui/screens/HomeScreen.js +9 -0
- package/dist/ui/screens/MapScreen.js +59 -0
- package/dist/ui/screens/NowPlayingScreen.js +79 -0
- package/dist/ui/screens/SearchScreen.js +12 -0
- package/dist/ui/screens/SettingsScreen.js +38 -0
- package/dist/ui/screens/StationScreen.js +7 -0
- package/dist/ui/screens/StatsScreen.js +90 -0
- package/dist/ui/terminal-mouse.js +38 -0
- package/dist/ui/theme.js +105 -0
- package/dist/ui/use-app-input.js +387 -0
- package/dist/ui/use-command-executor.js +156 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
- package/dist/ui/world-map.js +274 -0
- package/docs/THIRD_PARTY_NOTICES.md +33 -0
- package/package.json +83 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { themeAccent } from './theme.js';
|
|
4
|
+
import { HomeScreen } from './screens/HomeScreen.js';
|
|
5
|
+
import { CountriesScreen } from './screens/CountriesScreen.js';
|
|
6
|
+
import { MapScreen } from './screens/MapScreen.js';
|
|
7
|
+
import { SearchScreen } from './screens/SearchScreen.js';
|
|
8
|
+
import { ExploreScreen } from './screens/ExploreScreen.js';
|
|
9
|
+
import { StationScreen } from './screens/StationScreen.js';
|
|
10
|
+
import { NowPlayingScreen } from './screens/NowPlayingScreen.js';
|
|
11
|
+
import { StatsScreen } from './screens/StatsScreen.js';
|
|
12
|
+
import { SettingsScreen } from './screens/SettingsScreen.js';
|
|
13
|
+
export function AppContent({ backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme }) {
|
|
14
|
+
if (layout.compact) {
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "RadioCLI" }), _jsxs(Text, { color: themeAccent(theme), children: ["Terminal too small: ", layout.columns, "x", layout.rows] }), _jsx(Text, { color: "gray", children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: "gray", children: ["Playback: ", playback.state, " \u00B7 ", playback.backend] }), _jsx(Text, { color: "gray", children: "q quit \u00B7 Ctrl+C always exits" })] }));
|
|
16
|
+
}
|
|
17
|
+
if (screen === 'home') {
|
|
18
|
+
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library, playback: playback });
|
|
19
|
+
}
|
|
20
|
+
if (screen === 'countries') {
|
|
21
|
+
return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth }));
|
|
22
|
+
}
|
|
23
|
+
if (screen === 'map') {
|
|
24
|
+
return (_jsx(MapScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.mapCountryRows, mode: layout.mapMode, width: frameWidth }));
|
|
25
|
+
}
|
|
26
|
+
if (screen === 'search') {
|
|
27
|
+
return (_jsx(SearchScreen, { query: searchQuery, editing: editingSearch, loading: loadingStations, stations: displayStations, selected: selected, theme: theme, favorites: favoriteKeys, experimentalOn: library.settings.enableRadioGarden, filterLabel: filterLabel, pageSize: layout.stationRows, width: frameWidth }));
|
|
28
|
+
}
|
|
29
|
+
if (screen === 'explore') {
|
|
30
|
+
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 }));
|
|
31
|
+
}
|
|
32
|
+
if (screen === 'nearby' || screen === 'stations' || screen === 'library') {
|
|
33
|
+
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 }));
|
|
34
|
+
}
|
|
35
|
+
if (screen === 'now-playing') {
|
|
36
|
+
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics, sleepLabel: sleepLabel, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, width: layout.receiverWidth, height: layout.receiverRows }));
|
|
37
|
+
}
|
|
38
|
+
if (screen === 'stats') {
|
|
39
|
+
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
40
|
+
}
|
|
41
|
+
if (screen === 'settings') {
|
|
42
|
+
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
|
|
43
|
+
}
|
|
44
|
+
return _jsx(Text, { children: "Unknown screen." });
|
|
45
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
const emptyMediaKeyBindings = {
|
|
2
|
+
previous: [],
|
|
3
|
+
playPause: [],
|
|
4
|
+
next: []
|
|
5
|
+
};
|
|
6
|
+
const mediaTransportActions = ['previous', 'playPause', 'next'];
|
|
7
|
+
const sleepTimerOptions = [null, 15, 30, 60];
|
|
8
|
+
export const defaultExploreCursor = {
|
|
9
|
+
latitude: 48.8566,
|
|
10
|
+
longitude: 2.3522
|
|
11
|
+
};
|
|
12
|
+
export const initialStationContexts = {
|
|
13
|
+
explore: {
|
|
14
|
+
title: 'Explore world',
|
|
15
|
+
subtitle: `Map cursor near ${formatExploreCursor(defaultExploreCursor)} · scans the full geotagged atlas`,
|
|
16
|
+
stations: []
|
|
17
|
+
},
|
|
18
|
+
stations: {
|
|
19
|
+
title: 'Country stations',
|
|
20
|
+
subtitle: 'Pick a country to load stations',
|
|
21
|
+
stations: []
|
|
22
|
+
},
|
|
23
|
+
search: {
|
|
24
|
+
title: 'Search',
|
|
25
|
+
subtitle: 'Matches across enabled public station directories',
|
|
26
|
+
stations: []
|
|
27
|
+
},
|
|
28
|
+
nearby: {
|
|
29
|
+
title: 'Nearby',
|
|
30
|
+
subtitle: 'Opt-in approximate location for local stations',
|
|
31
|
+
stations: []
|
|
32
|
+
},
|
|
33
|
+
library: {
|
|
34
|
+
title: 'Library',
|
|
35
|
+
subtitle: 'Favorites, recent stations, and imported streams',
|
|
36
|
+
stations: []
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
export const topTabs = [
|
|
40
|
+
{ screen: 'home', label: 'Overview' },
|
|
41
|
+
{ screen: 'now-playing', label: 'Playing' },
|
|
42
|
+
{ screen: 'library', label: 'Library' },
|
|
43
|
+
{ screen: 'explore', label: 'Explore' },
|
|
44
|
+
{ screen: 'search', label: 'Search' },
|
|
45
|
+
{ screen: 'countries', label: 'Countries' },
|
|
46
|
+
{ screen: 'nearby', label: 'Nearby' },
|
|
47
|
+
{ screen: 'stats', label: 'Stats' },
|
|
48
|
+
{ screen: 'settings', label: 'Settings' }
|
|
49
|
+
];
|
|
50
|
+
export function clamp(value, max) {
|
|
51
|
+
return Math.min(Math.max(value, 0), Math.max(max, 0));
|
|
52
|
+
}
|
|
53
|
+
export function clampVolume(value) {
|
|
54
|
+
return Math.min(100, Math.max(0, Math.round(value)));
|
|
55
|
+
}
|
|
56
|
+
export function applyStationFilters(stations, filters) {
|
|
57
|
+
return stations.filter(station => {
|
|
58
|
+
if (filters.codec && station.codec && station.codec.toLowerCase() !== filters.codec.toLowerCase()) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (filters.language && station.language && !station.language.toLowerCase().includes(filters.language.toLowerCase())) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (filters.minBitrate && (station.bitrate ?? 0) < filters.minBitrate) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function formatFilterLabel(filters) {
|
|
71
|
+
const parts = [
|
|
72
|
+
filters.codec ? `codec ${filters.codec}` : undefined,
|
|
73
|
+
filters.language ? `language ${filters.language}` : undefined,
|
|
74
|
+
filters.minBitrate ? `min ${filters.minBitrate} kbps` : undefined
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
return parts.join(' · ') || 'none';
|
|
77
|
+
}
|
|
78
|
+
export function formatTimeLeft(ms) {
|
|
79
|
+
if (ms <= 0) {
|
|
80
|
+
return 'now';
|
|
81
|
+
}
|
|
82
|
+
const minutes = Math.ceil(ms / 60_000);
|
|
83
|
+
return `${minutes}m`;
|
|
84
|
+
}
|
|
85
|
+
export function nextSleepTimerMinutes(currentMinutes) {
|
|
86
|
+
const currentIndex = sleepTimerOptions.findIndex(option => option === currentMinutes);
|
|
87
|
+
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % sleepTimerOptions.length : 0;
|
|
88
|
+
return sleepTimerOptions[nextIndex] ?? null;
|
|
89
|
+
}
|
|
90
|
+
export function moveExploreCursor(cursor, direction, fast = false) {
|
|
91
|
+
const latitudeStep = fast ? 6 : 1;
|
|
92
|
+
const longitudeStep = fast ? 12 : 2;
|
|
93
|
+
const latitudeDelta = direction === 'up' ? latitudeStep : direction === 'down' ? -latitudeStep : 0;
|
|
94
|
+
const longitudeDelta = direction === 'right' ? longitudeStep : direction === 'left' ? -longitudeStep : 0;
|
|
95
|
+
return {
|
|
96
|
+
latitude: Math.min(84, Math.max(-84, cursor.latitude + latitudeDelta)),
|
|
97
|
+
longitude: wrapLongitude(cursor.longitude + longitudeDelta)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function formatExploreCursor(cursor) {
|
|
101
|
+
const latitude = `${Math.abs(cursor.latitude).toFixed(1)}${cursor.latitude >= 0 ? 'N' : 'S'}`;
|
|
102
|
+
const longitude = `${Math.abs(cursor.longitude).toFixed(1)}${cursor.longitude >= 0 ? 'E' : 'W'}`;
|
|
103
|
+
return `${latitude}, ${longitude}`;
|
|
104
|
+
}
|
|
105
|
+
function wrapLongitude(longitude) {
|
|
106
|
+
if (!Number.isFinite(longitude)) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
let wrapped = longitude;
|
|
110
|
+
while (wrapped > 180) {
|
|
111
|
+
wrapped -= 360;
|
|
112
|
+
}
|
|
113
|
+
while (wrapped < -180) {
|
|
114
|
+
wrapped += 360;
|
|
115
|
+
}
|
|
116
|
+
return wrapped;
|
|
117
|
+
}
|
|
118
|
+
export function stationApproximateTime(station) {
|
|
119
|
+
if (!station || typeof station.longitude !== 'number') {
|
|
120
|
+
return 'unknown';
|
|
121
|
+
}
|
|
122
|
+
const offsetHours = Math.round(station.longitude / 15);
|
|
123
|
+
const date = new Date(Date.now() + offsetHours * 60 * 60 * 1000);
|
|
124
|
+
const sign = offsetHours >= 0 ? '+' : '';
|
|
125
|
+
return `${date.toISOString().slice(11, 16)} UTC${sign}${offsetHours}`;
|
|
126
|
+
}
|
|
127
|
+
export function favoriteTarget(screen, selectedStation, playingStation) {
|
|
128
|
+
if (screen === 'now-playing') {
|
|
129
|
+
return playingStation;
|
|
130
|
+
}
|
|
131
|
+
if (screen === 'explore' || screen === 'stations' || screen === 'search' || screen === 'nearby' || screen === 'library') {
|
|
132
|
+
return selectedStation;
|
|
133
|
+
}
|
|
134
|
+
return playingStation;
|
|
135
|
+
}
|
|
136
|
+
export function activeTabForScreen(screen) {
|
|
137
|
+
return screen === 'stations' || screen === 'map' ? 'countries' : screen;
|
|
138
|
+
}
|
|
139
|
+
export function stationContextKeyForScreen(screen) {
|
|
140
|
+
if (screen === 'explore' ||
|
|
141
|
+
screen === 'stations' ||
|
|
142
|
+
screen === 'search' ||
|
|
143
|
+
screen === 'nearby' ||
|
|
144
|
+
screen === 'library') {
|
|
145
|
+
return screen;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
export function shouldAnimateReceiver(screen, playback) {
|
|
150
|
+
return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
|
|
151
|
+
}
|
|
152
|
+
export function isEditableInput(input, key) {
|
|
153
|
+
return Boolean(key.backspace || key.delete || input);
|
|
154
|
+
}
|
|
155
|
+
export function applyTextInput(value, input, key) {
|
|
156
|
+
if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
|
|
157
|
+
return value.slice(0, -1);
|
|
158
|
+
}
|
|
159
|
+
if (key.ctrl || key.meta) {
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
let next = value;
|
|
163
|
+
for (const character of input) {
|
|
164
|
+
if (character === '\u007f' || character === '\b') {
|
|
165
|
+
next = next.slice(0, -1);
|
|
166
|
+
}
|
|
167
|
+
else if (character >= ' ') {
|
|
168
|
+
next += character;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
export function mediaTransportActionForInput(input, mediaKeys = emptyMediaKeyBindings) {
|
|
174
|
+
const normalized = normalizeMediaKeyBindings(mediaKeys);
|
|
175
|
+
for (const action of mediaTransportActions) {
|
|
176
|
+
if (new Set(normalized[action]).has(input)) {
|
|
177
|
+
return action;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const matches = [...input.matchAll(/\u001B\[(18|19|20)(?:;\d+)?~/g)];
|
|
181
|
+
const keyCode = matches.at(-1)?.[1];
|
|
182
|
+
if (keyCode === '18') {
|
|
183
|
+
return 'previous';
|
|
184
|
+
}
|
|
185
|
+
if (keyCode === '19') {
|
|
186
|
+
return 'playPause';
|
|
187
|
+
}
|
|
188
|
+
if (keyCode === '20') {
|
|
189
|
+
return 'next';
|
|
190
|
+
}
|
|
191
|
+
const kittyMatches = [...input.matchAll(/\u001B\[(57428|57429|57430|57431|57433|57434|57435|57436)(?:;[0-9:;]+)?u/g)];
|
|
192
|
+
const kittyCode = kittyMatches.at(-1)?.[1];
|
|
193
|
+
if (kittyCode === '57431' || kittyCode === '57434' || kittyCode === '57436') {
|
|
194
|
+
return 'previous';
|
|
195
|
+
}
|
|
196
|
+
if (kittyCode === '57428' || kittyCode === '57429' || kittyCode === '57430') {
|
|
197
|
+
return 'playPause';
|
|
198
|
+
}
|
|
199
|
+
if (kittyCode === '57433' || kittyCode === '57435') {
|
|
200
|
+
return 'next';
|
|
201
|
+
}
|
|
202
|
+
const modifiedArrowMatches = [...input.matchAll(/\u001B\[1;[235]([CD])/g)];
|
|
203
|
+
const modifiedArrow = modifiedArrowMatches.at(-1)?.[1];
|
|
204
|
+
if (modifiedArrow === 'D') {
|
|
205
|
+
return 'previous';
|
|
206
|
+
}
|
|
207
|
+
if (modifiedArrow === 'C') {
|
|
208
|
+
return 'next';
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
export function normalizeMediaKeyBindings(mediaKeys) {
|
|
213
|
+
return {
|
|
214
|
+
previous: uniqueStrings(mediaKeys?.previous ?? []),
|
|
215
|
+
playPause: uniqueStrings(mediaKeys?.playPause ?? []),
|
|
216
|
+
next: uniqueStrings(mediaKeys?.next ?? [])
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
export function addMediaKeyBinding(mediaKeys, action, input) {
|
|
220
|
+
const normalized = normalizeMediaKeyBindings(mediaKeys);
|
|
221
|
+
return {
|
|
222
|
+
...normalized,
|
|
223
|
+
[action]: uniqueStrings([...normalized[action], input]).slice(-8)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export function mediaActionLabel(action) {
|
|
227
|
+
if (action === 'playPause') {
|
|
228
|
+
return 'play/pause';
|
|
229
|
+
}
|
|
230
|
+
return action;
|
|
231
|
+
}
|
|
232
|
+
export function parseMediaActionName(value) {
|
|
233
|
+
const normalized = value.toLowerCase().replace(/[^a-z]/g, '');
|
|
234
|
+
if (normalized === 'previous' || normalized === 'prev' || normalized === 'back') {
|
|
235
|
+
return 'previous';
|
|
236
|
+
}
|
|
237
|
+
if (normalized === 'playpause' || normalized === 'pause' || normalized === 'play') {
|
|
238
|
+
return 'playPause';
|
|
239
|
+
}
|
|
240
|
+
if (normalized === 'next' || normalized === 'forward') {
|
|
241
|
+
return 'next';
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
export function isPlainPrintableInput(input) {
|
|
246
|
+
return input.length > 0 && [...input].every(character => character >= ' ' && character !== '\u007f');
|
|
247
|
+
}
|
|
248
|
+
function uniqueStrings(values) {
|
|
249
|
+
return [...new Set(values.filter(value => value.length > 0))];
|
|
250
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
|
|
4
|
+
function LogoSpectrum() {
|
|
5
|
+
return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
|
|
6
|
+
}
|
|
7
|
+
export function Logo() {
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color: "gray", children: "Live public radio from around the world" })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function Menu({ items, selected, keyFor, render }) {
|
|
4
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === selected) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
|
|
5
|
+
}
|
|
6
|
+
export function Pointer({ active }) {
|
|
7
|
+
return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
|
|
8
|
+
}
|
|
9
|
+
function defaultMenuKey(item) {
|
|
10
|
+
if (typeof item === 'string' || typeof item === 'number') {
|
|
11
|
+
return item;
|
|
12
|
+
}
|
|
13
|
+
if (item && typeof item === 'object') {
|
|
14
|
+
const record = item;
|
|
15
|
+
for (const field of ['id', 'code', 'screen', 'label', 'name']) {
|
|
16
|
+
const value = record[field];
|
|
17
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
18
|
+
return `${field}:${value}`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return JSON.stringify(item);
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { textDim, themeAccent } from '../theme.js';
|
|
4
|
+
import { truncate } from '../format.js';
|
|
5
|
+
// One title treatment shared by every screen: an accented, bold title trailed by
|
|
6
|
+
// a dim rule that fills the frame, with optional right-aligned meta and a muted
|
|
7
|
+
// subtitle below. Renders one row (plus one for the subtitle when present).
|
|
8
|
+
export function ScreenHeader({ title, width, theme, subtitle, right }) {
|
|
9
|
+
const accent = themeAccent(theme);
|
|
10
|
+
const safeTitle = truncate(title, Math.max(4, width - 4));
|
|
11
|
+
const rightText = right ? ` ${right}` : '';
|
|
12
|
+
const ruleWidth = Math.max(1, width - safeTitle.length - rightText.length - 2);
|
|
13
|
+
const rule = '─'.repeat(ruleWidth);
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: safeTitle }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: "gray", children: rightText }) : null] }), subtitle ? _jsx(Text, { color: "gray", children: truncate(subtitle, width) }) : null] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
4
|
+
import { themeAccent } from '../theme.js';
|
|
5
|
+
import { Menu, Pointer } from './Menu.js';
|
|
6
|
+
import { visibleWindow } from '../list-window.js';
|
|
7
|
+
export function StationList({ stations, selected, theme, favorites, pageSize, width }) {
|
|
8
|
+
if (stations.length === 0) {
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No stations here yet." }), _jsx(Text, { color: "gray", children: "Try a different search or country, or clear active filters with :clear." })] }));
|
|
10
|
+
}
|
|
11
|
+
const window = visibleWindow(stations, selected, pageSize);
|
|
12
|
+
const rowWidth = Math.max(42, width - 4);
|
|
13
|
+
const nameWidth = Math.min(48, Math.max(18, Math.floor(rowWidth * 0.42)));
|
|
14
|
+
const metaWidth = Math.max(12, rowWidth - nameWidth - 6);
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Showing ", window.start + 1, "-", window.end, " of ", stations.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station, index, active) => {
|
|
16
|
+
const favorite = favorites.has(`${station.provider}:${station.id}`);
|
|
17
|
+
const stationName = truncate(station.name, favorite ? Math.max(1, nameWidth - 2) : nameWidth);
|
|
18
|
+
const titleWidth = nameWidth + 2;
|
|
19
|
+
const titleUsed = stationName.length + (favorite ? 2 : 0);
|
|
20
|
+
const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: "gray", children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children: _jsxs(Text, { color: "gray", children: ["#", window.start + index + 1, " \u00B7 ", truncate(stationTags(station), rowWidth - 8)] }) })) : null] }));
|
|
22
|
+
} })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { panelBackground, panelBorder, themeAccent } from '../theme.js';
|
|
4
|
+
import { truncate } from '../format.js';
|
|
5
|
+
export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
6
|
+
const accent = themeAccent(theme);
|
|
7
|
+
const brand = 'RADIOCLI';
|
|
8
|
+
const rightText = ` ${rightLabel} `;
|
|
9
|
+
const bodyWidth = Math.max(1, width - 4);
|
|
10
|
+
const titlePrefixWidth = 2 + brand.length + 1;
|
|
11
|
+
const titleRuleWidth = Math.max(0, width - titlePrefixWidth - 1);
|
|
12
|
+
const allTabsWidth = tabsWidth(tabs.map(tab => ({ type: 'tab', tab })));
|
|
13
|
+
const canShowRight = bodyWidth - allTabsWidth - rightText.length >= 1;
|
|
14
|
+
const tabsAvailableWidth = Math.max(1, bodyWidth - (canShowRight ? rightText.length + 1 : 0));
|
|
15
|
+
const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
|
|
16
|
+
const visibleTabsWidth = tabsWidth(visibleTabs);
|
|
17
|
+
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", backgroundColor: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, children: [_jsx(Text, { color: panelBorder, children: "\u250C " }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", '─'.repeat(titleRuleWidth), "\u2510"] })] }), _jsxs(Text, { backgroundColor: panelBackground, children: [_jsx(Text, { color: panelBorder, children: "\u2502 " }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: "gray", children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : 'gray', bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsx(Text, { color: "gray", children: " \u2502 " }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: "gray", children: rightText }) : null, _jsx(Text, { color: panelBorder, children: " \u2502" })] }), _jsxs(Text, { backgroundColor: panelBackground, color: panelBorder, children: ["\u2514", '─'.repeat(Math.max(0, width - 2)), "\u2518"] })] }));
|
|
19
|
+
}
|
|
20
|
+
function fitTabs(tabs, active, maxWidth) {
|
|
21
|
+
const all = tabs.map(tab => ({ type: 'tab', tab }));
|
|
22
|
+
if (tabsWidth(all) <= maxWidth) {
|
|
23
|
+
return all;
|
|
24
|
+
}
|
|
25
|
+
const activeIndex = Math.max(0, tabs.findIndex(tab => tab.screen === active));
|
|
26
|
+
let firstIndex = activeIndex;
|
|
27
|
+
let lastIndex = activeIndex;
|
|
28
|
+
let blockedLeft = false;
|
|
29
|
+
let blockedRight = false;
|
|
30
|
+
while (!blockedLeft || !blockedRight) {
|
|
31
|
+
if (!blockedLeft) {
|
|
32
|
+
if (firstIndex === 0) {
|
|
33
|
+
blockedLeft = true;
|
|
34
|
+
}
|
|
35
|
+
else if (tabsWidth(segmentsForRange(tabs, firstIndex - 1, lastIndex)) <= maxWidth) {
|
|
36
|
+
firstIndex -= 1;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
blockedLeft = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!blockedRight) {
|
|
43
|
+
if (lastIndex === tabs.length - 1) {
|
|
44
|
+
blockedRight = true;
|
|
45
|
+
}
|
|
46
|
+
else if (tabsWidth(segmentsForRange(tabs, firstIndex, lastIndex + 1)) <= maxWidth) {
|
|
47
|
+
lastIndex += 1;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
blockedRight = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const segments = segmentsForRange(tabs, firstIndex, lastIndex);
|
|
55
|
+
if (tabsWidth(segments) <= maxWidth) {
|
|
56
|
+
return segments;
|
|
57
|
+
}
|
|
58
|
+
const activeTab = tabs[activeIndex] ?? tabs[0];
|
|
59
|
+
return [{ type: 'tab', tab: { ...activeTab, label: truncate(activeTab.label, Math.max(1, maxWidth)) } }];
|
|
60
|
+
}
|
|
61
|
+
function segmentsForRange(tabs, firstIndex, lastIndex) {
|
|
62
|
+
const segments = [];
|
|
63
|
+
if (firstIndex > 0) {
|
|
64
|
+
segments.push({ type: 'overflow', side: 'left' });
|
|
65
|
+
}
|
|
66
|
+
for (let index = firstIndex; index <= lastIndex; index += 1) {
|
|
67
|
+
const tab = tabs[index];
|
|
68
|
+
if (tab) {
|
|
69
|
+
segments.push({ type: 'tab', tab });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (lastIndex < tabs.length - 1) {
|
|
73
|
+
segments.push({ type: 'overflow', side: 'right' });
|
|
74
|
+
}
|
|
75
|
+
return segments;
|
|
76
|
+
}
|
|
77
|
+
function tabsWidth(segments) {
|
|
78
|
+
return segments.reduce((total, segment, index) => {
|
|
79
|
+
const labelWidth = segment.type === 'overflow' ? 1 : segment.tab.label.length;
|
|
80
|
+
return total + labelWidth + (index > 0 ? 3 : 0);
|
|
81
|
+
}, 0);
|
|
82
|
+
}
|