@ciphore/radiocli 0.2.0 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +36 -2
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +9 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +93 -21
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
@@ -1,6 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
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 { playbackBackendLabel } from '../player/backend-install.js';
16
+ import { AdaptiveContent } from './AdaptiveContent.js';
18
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 (_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: textMuted, children: "Resize to at least 64x18 for the full receiver UI." }), _jsxs(Text, { color: textMuted, children: ["Playback: ", playback.state, " \u00B7 ", playbackBackendLabel(playback.backend)] }), _jsx(Text, { color: textMuted, children: "q quit \u00B7 Ctrl+C always exits" })] }));
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, playback: playback });
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, appVersion, backends,
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.stationRows, width: frameWidth }));
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,7 +37,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
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, sleepLabel: sleepLabel, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, trackHistory: library.trackHistory, width: layout.receiverWidth, height: layout.receiverRows }));
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 });
@@ -47,13 +46,13 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
47
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
  }
@@ -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: 'Opt-in approximate location for local stations',
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
- { screen: 'home', label: 'Overview' },
43
- { screen: 'now-playing', label: 'Playing' },
44
- { screen: 'library', label: 'Library' },
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 && nextStation && !isPlaybackOutputError(error));
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
- if (current.receiverStyle !== 'ultracode') {
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 === 'ultracode' &&
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.slice(0, -1);
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
- return '*';
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.length - rightText.length - 2);
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: "No stations here yet." }), _jsx(Text, { color: textMuted, children: "Try a different search or country, or clear active filters with :clear." })] }));
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: ["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) => {
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.length + (favorite ? 2 : 0);
19
+ const titleUsed = displayWidth(stationName) + (favorite ? 2 : 0);
20
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: textMuted, children: truncate(`${stationLocation(station)} · ${stationTech(station)}`, metaWidth) })] }), active ? (_jsx(Box, { marginLeft: 4, children: _jsx(Text, { color: textMuted, children: truncate(stationTags(station), rowWidth - 4) }) })) : null] }));
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, rightLabel }) {
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 = ` ${rightLabel} `;
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 canShowRight = bodyWidth - allTabsWidth - rightText.length >= 1;
19
- const tabsAvailableWidth = Math.max(1, bodyWidth - (canShowRight ? rightText.length + 1 : 0));
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 ? rightText.length : 0));
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.length;
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
  }