@ciphore/radiocli 0.1.9 → 0.2.1
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 +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +109 -10
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -214
package/dist/ui/AppContent.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import {
|
|
3
|
-
import { textMuted, themeAccent } from './theme.js';
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
4
3
|
import { HomeScreen } from './screens/HomeScreen.js';
|
|
5
4
|
import { CountriesScreen } from './screens/CountriesScreen.js';
|
|
6
5
|
import { MapScreen } from './screens/MapScreen.js';
|
|
@@ -14,13 +13,13 @@ import { HelpScreen } from './screens/HelpScreen.js';
|
|
|
14
13
|
import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
|
|
15
14
|
import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
|
|
16
15
|
import { selectedAirPlayDevice } from './airplay-settings.js';
|
|
17
|
-
import {
|
|
18
|
-
export function AppContent({ airPlayDevices, airPlayCode, 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 }) {
|
|
16
|
+
import { AdaptiveContent } from './AdaptiveContent.js';
|
|
17
|
+
export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, pulse, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck }) {
|
|
19
18
|
if (layout.compact) {
|
|
20
|
-
return (
|
|
19
|
+
return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, filterLabel: filterLabel, pulse: pulse, sleepLabel: sleepLabel }));
|
|
21
20
|
}
|
|
22
21
|
if (screen === 'home') {
|
|
23
|
-
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library
|
|
22
|
+
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library });
|
|
24
23
|
}
|
|
25
24
|
if (screen === 'countries') {
|
|
26
25
|
return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth }));
|
|
@@ -29,7 +28,7 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
|
|
|
29
28
|
return (_jsx(MapScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.mapCountryRows, mode: layout.mapMode, width: frameWidth }));
|
|
30
29
|
}
|
|
31
30
|
if (screen === 'search') {
|
|
32
|
-
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.
|
|
31
|
+
return (_jsx(SearchScreen, { query: searchQuery, editing: editingSearch, loading: loadingStations, stations: displayStations, selected: selected, theme: theme, favorites: favoriteKeys, experimentalOn: library.settings.enableRadioGarden, filterLabel: filterLabel, pageSize: Math.max(1, layout.contentRows - 8), width: frameWidth }));
|
|
33
32
|
}
|
|
34
33
|
if (screen === 'explore') {
|
|
35
34
|
return (_jsx(ExploreScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, cursor: exploreCursor, pageSize: layout.stationRows, width: frameWidth, height: layout.contentRows }));
|
|
@@ -38,22 +37,22 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
|
|
|
38
37
|
return (_jsx(StationScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, pageSize: layout.stationRows, width: frameWidth }));
|
|
39
38
|
}
|
|
40
39
|
if (screen === 'now-playing') {
|
|
41
|
-
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics,
|
|
40
|
+
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, trackHistory: library.trackHistory, width: layout.receiverWidth, height: layout.receiverRows }));
|
|
42
41
|
}
|
|
43
42
|
if (screen === 'stats') {
|
|
44
43
|
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
45
44
|
}
|
|
46
45
|
if (screen === 'settings') {
|
|
47
|
-
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth }));
|
|
46
|
+
return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
|
|
48
47
|
}
|
|
49
48
|
if (screen === 'airplay-settings') {
|
|
50
|
-
return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth }));
|
|
49
|
+
return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
|
|
51
50
|
}
|
|
52
51
|
if (screen === 'airplay-code') {
|
|
53
52
|
return (_jsx(AirPlayCodeScreen, { code: airPlayCode, playback: playback, selectedDevice: selectedAirPlayDevice(library.settings, airPlayDevices), theme: theme, width: frameWidth }));
|
|
54
53
|
}
|
|
55
54
|
if (screen === 'help') {
|
|
56
|
-
return _jsx(HelpScreen, { theme: theme, width: frameWidth });
|
|
55
|
+
return _jsx(HelpScreen, { theme: theme, width: frameWidth, height: layout.contentRows, selected: selected });
|
|
57
56
|
}
|
|
58
57
|
return _jsx(Text, { children: "Unknown screen." });
|
|
59
58
|
}
|
package/dist/ui/app-state.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { isPlaybackOutputError } from '../player/player-controller.js';
|
|
2
|
+
import { isProviderUnavailableError } from '../providers/radio-browser.js';
|
|
3
|
+
import { receiverStyleMetadata } from './visualizers/receiver-style-registry.js';
|
|
4
|
+
import { removeLastGrapheme } from './format.js';
|
|
5
|
+
import { primaryScreens, screenMetadata } from './screen-meta.js';
|
|
2
6
|
const emptyMediaKeyBindings = {
|
|
3
7
|
previous: [],
|
|
4
8
|
playPause: [],
|
|
@@ -29,7 +33,7 @@ export const initialStationContexts = {
|
|
|
29
33
|
},
|
|
30
34
|
nearby: {
|
|
31
35
|
title: 'Nearby',
|
|
32
|
-
subtitle: '
|
|
36
|
+
subtitle: 'Approximate IP location for local stations',
|
|
33
37
|
stations: []
|
|
34
38
|
},
|
|
35
39
|
library: {
|
|
@@ -38,17 +42,10 @@ export const initialStationContexts = {
|
|
|
38
42
|
stations: []
|
|
39
43
|
}
|
|
40
44
|
};
|
|
41
|
-
export const topTabs =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{ screen: 'explore', label: 'Explore' },
|
|
46
|
-
{ screen: 'search', label: 'Search' },
|
|
47
|
-
{ screen: 'countries', label: 'Countries' },
|
|
48
|
-
{ screen: 'nearby', label: 'Nearby' },
|
|
49
|
-
{ screen: 'stats', label: 'Stats' },
|
|
50
|
-
{ screen: 'settings', label: 'Settings' }
|
|
51
|
-
];
|
|
45
|
+
export const topTabs = primaryScreens.map(screen => ({
|
|
46
|
+
screen,
|
|
47
|
+
label: screenMetadata[screen].tabLabel
|
|
48
|
+
}));
|
|
52
49
|
export function clamp(value, max) {
|
|
53
50
|
return Math.min(Math.max(value, 0), Math.max(max, 0));
|
|
54
51
|
}
|
|
@@ -149,7 +146,10 @@ export function favoriteTarget(screen, selectedStation, playingStation) {
|
|
|
149
146
|
return playingStation;
|
|
150
147
|
}
|
|
151
148
|
export function shouldSkipAfterTuneError(error, skipBrokenStreams, nextStation) {
|
|
152
|
-
return Boolean(skipBrokenStreams &&
|
|
149
|
+
return Boolean(skipBrokenStreams &&
|
|
150
|
+
nextStation &&
|
|
151
|
+
!isPlaybackOutputError(error) &&
|
|
152
|
+
!isProviderUnavailableError(error));
|
|
153
153
|
}
|
|
154
154
|
export function activeTabForScreen(screen) {
|
|
155
155
|
if (screen === 'stations' || screen === 'map') {
|
|
@@ -180,7 +180,8 @@ export function nextReceiverPulse(pulse) {
|
|
|
180
180
|
return pulse + 1;
|
|
181
181
|
}
|
|
182
182
|
export function shouldResetReceiverPulse(previous, current) {
|
|
183
|
-
|
|
183
|
+
const style = receiverStyleMetadata[current.receiverStyle];
|
|
184
|
+
if (!('resetPulse' in style) || !style.resetPulse) {
|
|
184
185
|
return false;
|
|
185
186
|
}
|
|
186
187
|
const currentActive = current.screen === 'now-playing' &&
|
|
@@ -189,8 +190,17 @@ export function shouldResetReceiverPulse(previous, current) {
|
|
|
189
190
|
if (!currentActive) {
|
|
190
191
|
return false;
|
|
191
192
|
}
|
|
193
|
+
// Every receiver except Ultracode resumes from its frozen pause frame.
|
|
194
|
+
// Ultracode intentionally retains its active-signal restart behavior.
|
|
195
|
+
if (current.receiverStyle !== 'ultracode' &&
|
|
196
|
+
previous?.screen === 'now-playing' &&
|
|
197
|
+
previous.receiverStyle === current.receiverStyle &&
|
|
198
|
+
previous.playbackState === 'paused' &&
|
|
199
|
+
previous.playbackReady) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
192
202
|
return !(previous?.screen === 'now-playing' &&
|
|
193
|
-
previous.receiverStyle ===
|
|
203
|
+
previous.receiverStyle === current.receiverStyle &&
|
|
194
204
|
previous.playbackState === 'playing' &&
|
|
195
205
|
previous.playbackReady);
|
|
196
206
|
}
|
|
@@ -199,7 +209,7 @@ export function isEditableInput(input, key) {
|
|
|
199
209
|
}
|
|
200
210
|
export function applyTextInput(value, input, key) {
|
|
201
211
|
if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
|
|
202
|
-
return value
|
|
212
|
+
return removeLastGrapheme(value);
|
|
203
213
|
}
|
|
204
214
|
if (key.ctrl || key.meta) {
|
|
205
215
|
return value;
|
package/dist/ui/ascii.js
CHANGED
|
@@ -23,6 +23,17 @@ function asciiGlyph(code) {
|
|
|
23
23
|
if (code >= 0x2580 && code <= 0x259f) {
|
|
24
24
|
return '#';
|
|
25
25
|
}
|
|
26
|
+
// Diagonal box strokes need to retain their direction for wireframes,
|
|
27
|
+
// routes, folds, and other receiver geometry.
|
|
28
|
+
if (code === 0x2571) {
|
|
29
|
+
return '/';
|
|
30
|
+
}
|
|
31
|
+
if (code === 0x2572) {
|
|
32
|
+
return '\\';
|
|
33
|
+
}
|
|
34
|
+
if (code === 0x2573) {
|
|
35
|
+
return 'X';
|
|
36
|
+
}
|
|
26
37
|
// Box drawing.
|
|
27
38
|
if (code >= 0x2500 && code <= 0x257f) {
|
|
28
39
|
if (code === 0x2500 || code === 0x2501 || code === 0x2504 || code === 0x2505) {
|
|
@@ -50,6 +61,33 @@ function asciiGlyph(code) {
|
|
|
50
61
|
if (code === 0x25cb || code === 0x25aa || code === 0x25a0) {
|
|
51
62
|
return '*';
|
|
52
63
|
}
|
|
64
|
+
if (code === 0x25c9 || code === 0x25d2) {
|
|
65
|
+
return 'o'; // ◉ ◒
|
|
66
|
+
}
|
|
67
|
+
if (code === 0x25b2) {
|
|
68
|
+
return '^'; // ▲
|
|
69
|
+
}
|
|
70
|
+
if (code === 0x25bc) {
|
|
71
|
+
return 'v'; // ▼
|
|
72
|
+
}
|
|
73
|
+
if (code === 0x25e2 || code === 0x25e5) {
|
|
74
|
+
return '/'; // ◢ ◥
|
|
75
|
+
}
|
|
76
|
+
if (code === 0x25e3 || code === 0x25e4) {
|
|
77
|
+
return '\\'; // ◣ ◤
|
|
78
|
+
}
|
|
79
|
+
if (code === 0x22d6) {
|
|
80
|
+
return '<'; // ⋖
|
|
81
|
+
}
|
|
82
|
+
if (code === 0x22d7) {
|
|
83
|
+
return '>'; // ⋗
|
|
84
|
+
}
|
|
85
|
+
if (code === 0x2301) {
|
|
86
|
+
return '~'; // ⌁
|
|
87
|
+
}
|
|
88
|
+
if (code === 0x02d9) {
|
|
89
|
+
return '.'; // ˙
|
|
90
|
+
}
|
|
53
91
|
if (code === 0x2605) {
|
|
54
92
|
return '*'; // ★
|
|
55
93
|
}
|
|
@@ -63,7 +101,10 @@ function asciiGlyph(code) {
|
|
|
63
101
|
if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) {
|
|
64
102
|
return '';
|
|
65
103
|
}
|
|
66
|
-
|
|
104
|
+
// Unknown Unicode is user/content data more often than decoration. Preserve
|
|
105
|
+
// it instead of destroying international station names; known terminal-art
|
|
106
|
+
// ranges above still receive ASCII fallbacks.
|
|
107
|
+
return String.fromCodePoint(code);
|
|
67
108
|
}
|
|
68
109
|
function popcount(value) {
|
|
69
110
|
let count = 0;
|
|
@@ -5,6 +5,11 @@ const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e
|
|
|
5
5
|
function LogoSpectrum() {
|
|
6
6
|
return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
|
|
7
7
|
}
|
|
8
|
-
export function Logo() {
|
|
8
|
+
export function Logo({ compact = false, width = 80 } = {}) {
|
|
9
|
+
if (compact) {
|
|
10
|
+
const spectrumColumns = Math.max(1, width - 10);
|
|
11
|
+
const colors = logoSpectrumColors.slice(0, Math.min(logoSpectrumColors.length, spectrumColumns));
|
|
12
|
+
return (_jsxs(Box, { width: width, overflow: "hidden", children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), colors.map(color => _jsx(Text, { color: color, children: "\u2588" }, color))] }));
|
|
13
|
+
}
|
|
9
14
|
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: textMuted, children: "Live public radio from around the world" })] }));
|
|
10
15
|
}
|
|
@@ -3,7 +3,7 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
4
4
|
import { useDisplay } from '../display-context.js';
|
|
5
5
|
import { toAsciiSafe } from '../ascii.js';
|
|
6
|
-
import { truncate } from '../format.js';
|
|
6
|
+
import { displayWidth, truncate } from '../format.js';
|
|
7
7
|
// One title treatment shared by every screen: an accented, bold title trailed by
|
|
8
8
|
// a dim rule that fills the frame, with optional right-aligned meta and a muted
|
|
9
9
|
// subtitle below. Renders one row (plus one for the subtitle when present).
|
|
@@ -13,7 +13,7 @@ export function ScreenHeader({ title, width, theme, subtitle, right }) {
|
|
|
13
13
|
const a = (value) => (ascii ? toAsciiSafe(value) : value);
|
|
14
14
|
const safeTitle = truncate(title, Math.max(4, width - 4));
|
|
15
15
|
const rightText = right ? ` ${right}` : '';
|
|
16
|
-
const ruleWidth = Math.max(1, width - safeTitle
|
|
16
|
+
const ruleWidth = Math.max(1, width - displayWidth(safeTitle) - displayWidth(rightText) - 2);
|
|
17
17
|
const rule = (ascii ? '-' : '─').repeat(ruleWidth);
|
|
18
18
|
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: textMuted, children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: textMuted, children: a(truncate(subtitle, width)) }) : null] }));
|
|
19
19
|
}
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
3
|
+
import { displayWidth, stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
4
4
|
import { textMuted, themeAccent } from '../theme.js';
|
|
5
5
|
import { Menu, Pointer } from './Menu.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
|
-
export function StationList({ stations, selected, theme, favorites, pageSize, width }) {
|
|
7
|
+
export function StationList({ stations, selected, theme, favorites, pageSize, width, emptyTitle = 'No stations found.', emptyHint = 'Try another view or clear active filters.', showCount = true }) {
|
|
8
8
|
if (stations.length === 0) {
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children:
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: emptyTitle }), _jsx(Text, { color: textMuted, children: emptyHint })] }));
|
|
10
10
|
}
|
|
11
11
|
const window = visibleWindow(stations, selected, pageSize);
|
|
12
12
|
const rowWidth = Math.max(42, width - 4);
|
|
13
13
|
const nameWidth = Math.min(48, Math.max(18, Math.floor(rowWidth * 0.42)));
|
|
14
14
|
const metaWidth = Math.max(12, rowWidth - nameWidth - 6);
|
|
15
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: [
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [showCount ? (_jsxs(Text, { color: textMuted, children: [window.start + 1, "-", window.end, " of ", stations.length] })) : null, _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: station => `${station.provider}:${station.id}`, render: (station, _index, active) => {
|
|
16
16
|
const favorite = favorites.has(`${station.provider}:${station.id}`);
|
|
17
17
|
const stationName = truncate(station.name, favorite ? Math.max(1, nameWidth - 2) : nameWidth);
|
|
18
18
|
const titleWidth = nameWidth + 2;
|
|
19
|
-
const titleUsed = stationName
|
|
19
|
+
const titleUsed = displayWidth(stationName) + (favorite ? 2 : 0);
|
|
20
20
|
const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
|
|
21
|
-
|
|
21
|
+
const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
|
|
22
|
+
const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
|
|
23
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: truncate(active ? selectedMetadata : standardMetadata, metaWidth) })] }));
|
|
22
24
|
} })] }));
|
|
23
25
|
}
|
|
@@ -2,24 +2,25 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { panelBorder, textMuted, themeAccent } from '../theme.js';
|
|
4
4
|
import { useDisplay } from '../display-context.js';
|
|
5
|
-
import { truncate } from '../format.js';
|
|
6
|
-
export function TopTabs({ tabs, active, theme, width,
|
|
5
|
+
import { displayWidth, truncate } from '../format.js';
|
|
6
|
+
export function TopTabs({ tabs, active, theme, width, backendLabel }) {
|
|
7
7
|
const accent = themeAccent(theme);
|
|
8
8
|
const { app: background, ascii } = useDisplay();
|
|
9
9
|
const box = ascii
|
|
10
10
|
? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
|
|
11
11
|
: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
|
|
12
12
|
const brand = 'RADIOCLI';
|
|
13
|
-
const rightText = ` ${
|
|
13
|
+
const rightText = ` ${backendLabel} `;
|
|
14
14
|
const bodyWidth = Math.max(1, width - 4);
|
|
15
15
|
const titlePrefixWidth = 2 + brand.length + 1;
|
|
16
16
|
const titleRuleWidth = Math.max(0, width - titlePrefixWidth - 1);
|
|
17
17
|
const allTabsWidth = tabsWidth(tabs.map(tab => ({ type: 'tab', tab })));
|
|
18
|
-
const
|
|
19
|
-
const
|
|
18
|
+
const rightWidth = displayWidth(rightText);
|
|
19
|
+
const canShowRight = bodyWidth - allTabsWidth - rightWidth >= 1;
|
|
20
|
+
const tabsAvailableWidth = Math.max(1, bodyWidth - (canShowRight ? rightWidth + 1 : 0));
|
|
20
21
|
const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
|
|
21
22
|
const visibleTabsWidth = tabsWidth(visibleTabs);
|
|
22
|
-
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ?
|
|
23
|
+
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightWidth : 0));
|
|
23
24
|
return (_jsxs(Box, { flexDirection: "column", backgroundColor: background, width: width, children: [_jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: background, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: textMuted, children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : textMuted, bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: textMuted, children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: textMuted, children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: background, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
|
|
24
25
|
}
|
|
25
26
|
function fitTabs(tabs, active, maxWidth) {
|
|
@@ -81,7 +82,7 @@ function segmentsForRange(tabs, firstIndex, lastIndex) {
|
|
|
81
82
|
}
|
|
82
83
|
function tabsWidth(segments) {
|
|
83
84
|
return segments.reduce((total, segment, index) => {
|
|
84
|
-
const labelWidth = segment.type === 'overflow' ? 1 : segment.tab.label
|
|
85
|
+
const labelWidth = segment.type === 'overflow' ? 1 : displayWidth(segment.tab.label);
|
|
85
86
|
return total + labelWidth + (index > 0 ? 3 : 0);
|
|
86
87
|
}, 0);
|
|
87
88
|
}
|