@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +65 -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 +53 -3
  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 +179 -98
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +28 -8
  14. package/dist/providers/radio-browser.js +141 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +264 -149
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +489 -54
  20. package/dist/ui/AppContent.js +11 -12
  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 +10 -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 +15 -2
  35. package/dist/ui/screen-items.js +60 -21
  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 +109 -10
  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 +44 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +37 -6
  52. package/dist/ui/use-command-executor.js +34 -1
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/dist/update-check.js +122 -0
  56. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  57. package/package.json +2 -2
  58. package/dist/ui/screens/screen-render.test.js +0 -214
@@ -0,0 +1,332 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { computeListeningStats } from '../activity/stats.js';
4
+ import { playbackBackendLabel } from '../player/backend-install.js';
5
+ import { visibleWindow } from './list-window.js';
6
+ import { displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate } from './format.js';
7
+ import { homeItems, settingsItems, settingsSectionFor } from './screen-items.js';
8
+ import { screenTitle } from './screen-meta.js';
9
+ import { keyHelpSections, commandHelp } from './help-content.js';
10
+ import { settingLabel, settingValue } from './screens/SettingsScreen.js';
11
+ import { panelBorder, textDim, textMuted, themeAccent } from './theme.js';
12
+ import { panelBorderStyle, useDisplay } from './display-context.js';
13
+ import { toAsciiSafe } from './ascii.js';
14
+ import { buildVisualizer, visualizerHeight } from './visualizers/receiver-visualizers.js';
15
+ import { Logo } from './components/Logo.js';
16
+ export function AdaptiveContent(props) {
17
+ return _jsx(AdaptiveContentBody, { ...props });
18
+ }
19
+ function AdaptiveContentBody(props) {
20
+ const { mode, screen, selected, height, width, theme, playback, playingStation, nowPlaying, stations, countries, airPlayDevices, airPlayCode, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, library, diagnostics, backends, updateCheck, favoriteKeys, stationTitle, pulse, } = props;
21
+ const accent = themeAccent(theme);
22
+ const { ascii } = useDisplay();
23
+ const { bodyRows } = adaptiveFrameMetrics(mode, height);
24
+ const title = screenTitle(screen);
25
+ const status = adaptiveStatus(props);
26
+ if (screen === 'home') {
27
+ return (_jsx(AdaptiveHome, { mode: mode, selected: selected, height: height, width: width, theme: theme, library: library }));
28
+ }
29
+ if (screen === 'now-playing') {
30
+ return (_jsx(AdaptiveNowPlaying, { mode: mode, station: playingStation, playback: playback, metadata: nowPlaying, pulse: pulse, width: width, height: height, ascii: ascii, theme: theme, receiverStyle: library.settings.receiverStyle }));
31
+ }
32
+ if (screen === 'search') {
33
+ const rows = adaptiveRows({
34
+ screen,
35
+ stations,
36
+ countries,
37
+ airPlayDevices,
38
+ library,
39
+ diagnostics,
40
+ backends,
41
+ updateCheck,
42
+ favoriteKeys,
43
+ selected,
44
+ width,
45
+ mode,
46
+ ascii
47
+ });
48
+ const empty = adaptiveEmptyState({
49
+ screen,
50
+ searchQuery,
51
+ editingSearch,
52
+ countryFilter,
53
+ loadingCountries,
54
+ loadingStations,
55
+ nearbyEnabled: library.settings.enableNearbyLocation,
56
+ stationTitle
57
+ });
58
+ return (_jsx(AdaptiveSearch, { mode: mode, query: searchQuery, editing: editingSearch, loading: loadingStations, rows: rows, empty: empty, selected: selected, height: height, width: width, theme: theme, ascii: ascii }));
59
+ }
60
+ if (screen === 'stats') {
61
+ const stats = computeListeningStats(library.activity.sessions);
62
+ const activityWidth = Math.max(4, Math.min(28, width - 2));
63
+ const recentDays = stats.days.slice(-activityWidth);
64
+ const activity = recentDays.map(day => day.seconds > 0 ? (ascii ? '#' : '■') : (ascii ? '.' : '·')).join('');
65
+ const rows = [
66
+ { key: 'time', label: 'Listening', detail: formatHours(stats.totalSeconds / 3600) },
67
+ { key: 'favorite', label: 'Favorite', detail: stats.favoriteStation?.name ?? 'None yet' },
68
+ { key: 'sessions', label: 'Sessions', detail: stats.sessions.toLocaleString() },
69
+ { key: 'stations', label: 'Stations (2+ min)', detail: stats.listenedStationCount.toLocaleString() },
70
+ { key: 'streak', label: 'Streak', detail: `${stats.currentStreak} current · ${stats.longestStreak} best` },
71
+ { key: 'activity', label: activity, detail: `${stats.activeDays} active days` }
72
+ ];
73
+ return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: _jsx(StaticRows, { rows: rows.slice(0, bodyRows), width: width, theme: theme }) }));
74
+ }
75
+ if (screen === 'airplay-code') {
76
+ const prompt = airPlayCode ? `Code: ${airPlayCode}` : 'Type the code shown by the receiver.';
77
+ return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: bodyRows > 0 ? _jsx(Text, { color: airPlayCode ? accent : textMuted, children: truncate(prompt, width) }) : null }));
78
+ }
79
+ const rows = adaptiveRows({
80
+ screen,
81
+ stations,
82
+ countries,
83
+ airPlayDevices,
84
+ library,
85
+ diagnostics,
86
+ backends,
87
+ updateCheck,
88
+ favoriteKeys,
89
+ selected,
90
+ width,
91
+ mode,
92
+ ascii
93
+ });
94
+ const empty = adaptiveEmptyState({
95
+ screen,
96
+ searchQuery,
97
+ editingSearch,
98
+ countryFilter,
99
+ loadingCountries,
100
+ loadingStations,
101
+ nearbyEnabled: library.settings.enableNearbyLocation,
102
+ stationTitle
103
+ });
104
+ return (_jsx(AdaptiveFrame, { title: title, status: status, mode: mode, height: height, width: width, theme: theme, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: bodyRows, width: width, theme: theme })) : (_jsx(StaticRows, { rows: empty.slice(0, bodyRows), width: width, theme: theme })) }));
105
+ }
106
+ function AdaptiveFrame({ title, status, mode, height, width, theme, children }) {
107
+ const accent = themeAccent(theme);
108
+ const { ascii } = useDisplay();
109
+ const titlePrefix = mode === 'micro' ? 'RC / ' : 'RADIOCLI ';
110
+ const titleText = truncate(`${titlePrefix}${title}${mode === 'micro' && status ? ` · ${status}` : ''}`, width);
111
+ const ruleWidth = Math.max(0, width - displayWidth(titleText) - 1);
112
+ const { bodyRows, gapRows } = adaptiveFrameMetrics(mode, height);
113
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsxs(Text, { color: accent, bold: true, children: [titleText, _jsx(Text, { color: textDim, children: ruleWidth ? ` ${(ascii ? '-' : '─').repeat(ruleWidth)}` : '' })] }), mode === 'compact' && height >= 4 ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: bodyRows, overflow: "hidden", flexShrink: 0, children: children }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
114
+ }
115
+ function adaptiveFrameMetrics(mode, height) {
116
+ const headerRows = mode === 'compact' && height >= 4 ? 2 : 1;
117
+ const gapRows = height >= 5 ? 1 : 0;
118
+ return {
119
+ bodyRows: Math.max(0, height - headerRows - gapRows * 2),
120
+ gapRows
121
+ };
122
+ }
123
+ function AdaptiveHome({ mode, selected, height, width, theme, library }) {
124
+ const gapRows = height >= 5 ? 1 : 0;
125
+ const showSummary = mode === 'compact' && height >= 10;
126
+ const menuRows = Math.max(1, height - 1 - gapRows * 2 - (showSummary ? 1 : 0));
127
+ const selectedIndex = Math.min(Math.max(selected, 0), homeItems.length - 1);
128
+ const window = visibleWindow(homeItems, selectedIndex, menuRows);
129
+ const accent = themeAccent(theme);
130
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Logo, { compact: true, width: width }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: menuRows, overflow: "hidden", flexShrink: 0, children: window.items.map((item, offset) => {
131
+ const absoluteIndex = window.start + offset;
132
+ const active = absoluteIndex === selectedIndex;
133
+ const detail = mode === 'compact' && width >= 52 ? ` · ${item.detail}` : '';
134
+ return (_jsxs(Text, { color: active ? accent : undefined, bold: active, children: [active ? '> ' : ' ', absoluteIndex + 1, " ", truncate(`${item.label}${detail}`, Math.max(1, width - 4))] }, item.screen));
135
+ }) }), showSummary ? (_jsx(Text, { color: textMuted, children: truncate(`${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`, width) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
136
+ }
137
+ function AdaptiveList({ rows, selected, pageSize, width, theme }) {
138
+ const selectedIndex = Math.min(Math.max(selected, 0), Math.max(0, rows.length - 1));
139
+ const window = visibleWindow(rows, selectedIndex, Math.max(1, pageSize));
140
+ const accent = themeAccent(theme);
141
+ return (_jsx(Box, { flexDirection: "column", children: window.items.map((row, offset) => {
142
+ const absoluteIndex = window.start + offset;
143
+ const active = absoluteIndex === selectedIndex && !row.heading;
144
+ const prefix = row.heading ? ' ' : active ? '> ' : ' ';
145
+ const detail = row.detail ? `${row.separator ?? ' · '}${row.detail}` : '';
146
+ return (_jsxs(Text, { color: active ? accent : row.heading ? textMuted : undefined, bold: active || row.heading, children: [prefix, truncate(`${row.label}${detail}`, Math.max(0, width - 2))] }, row.key));
147
+ }) }));
148
+ }
149
+ function StaticRows({ rows, width, theme }) {
150
+ const accent = themeAccent(theme);
151
+ return (_jsx(Box, { flexDirection: "column", children: rows.map(row => (_jsxs(Text, { color: row.heading ? textMuted : undefined, bold: row.heading, children: [truncate(row.detail ? `${row.label} · ${row.detail}` : row.label, width), row.key === 'activity' ? _jsx(Text, { color: accent }) : null] }, row.key))) }));
152
+ }
153
+ function AdaptiveNowPlaying({ mode, station, playback, metadata, pulse, width, height, ascii, theme, receiverStyle }) {
154
+ const accent = themeAccent(theme);
155
+ const stationName = station?.name ?? 'No station tuned';
156
+ const showMetadata = mode === 'compact' && height >= 9 && Boolean(metadata?.title);
157
+ const headerRows = mode === 'compact' ? 2 : 1;
158
+ const metadataRows = showMetadata ? 1 : 0;
159
+ const gapRows = height >= 5 ? 1 : 0;
160
+ const availableVisualRows = Math.max(1, height - headerRows - metadataRows - gapRows * 2);
161
+ const visualHeight = visualizerHeight(receiverStyle, availableVisualRows, width);
162
+ const visualRows = buildVisualizer(receiverStyle, pulse, width, visualHeight, station, playback, theme, mode === 'micro' ? 'micro' : 'standard');
163
+ const header = mode === 'micro'
164
+ ? `${stationName} · ${playback.state}`
165
+ : 'RADIOCLI Now playing';
166
+ const status = `${stationName} ${playback.state}${playback.muted ? ' · muted' : ` · vol ${playback.volume}`}`;
167
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, children: truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
168
+ ? renderAdaptiveSegments(row.segments, ascii)
169
+ : ascii
170
+ ? toAsciiSafe(row.text)
171
+ : row.text }, index))) }), showMetadata ? _jsx(Text, { color: accent, children: truncate(metadata?.title ?? '', width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
172
+ }
173
+ function renderAdaptiveSegments(segments, ascii) {
174
+ let offset = 0;
175
+ return segments.map(segment => {
176
+ const key = `${offset}-${segment.color}-${segment.backgroundColor ?? ''}`;
177
+ offset += segment.text.length;
178
+ return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: ascii ? toAsciiSafe(segment.text) : segment.text }, key));
179
+ });
180
+ }
181
+ function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii }) {
182
+ const accent = themeAccent(theme);
183
+ const { panel: panelBackground } = useDisplay();
184
+ const fieldText = query || 'station, genre, or place';
185
+ const prefix = loading ? (ascii ? '* ' : '⣾ ') : editing ? '› ' : '/ ';
186
+ const fieldRows = height >= 4 ? 3 : 1;
187
+ const gapRows = height >= 6 ? 1 : 0;
188
+ const listRows = Math.max(0, height - 1 - fieldRows - gapRows * 2);
189
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: mode === 'micro' ? 'SEARCH' : 'RADIOCLI Search' }), fieldRows === 3 ? (_jsxs(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing || loading ? accent : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, height: 3, flexShrink: 0, children: [_jsx(Text, { color: editing || loading ? accent : textMuted, children: prefix }), _jsx(Text, { color: query ? accent : textMuted, children: truncate(fieldText, Math.max(1, width - 5)) })] })) : (_jsx(Text, { color: query ? accent : textMuted, children: truncate(`[${prefix}${fieldText}]`, width) })), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, _jsx(Box, { flexDirection: "column", height: listRows, overflow: "hidden", flexShrink: 0, children: rows.length > 0 ? (_jsx(AdaptiveList, { rows: rows, selected: selected, pageSize: listRows, width: width, theme: theme })) : (_jsx(StaticRows, { rows: empty.slice(0, listRows), width: width, theme: theme })) }), gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
190
+ }
191
+ function adaptiveRows(input) {
192
+ const { screen, stations, countries, airPlayDevices, library, diagnostics, backends, updateCheck, favoriteKeys, selected, width, mode, ascii } = input;
193
+ if (screen === 'home') {
194
+ return homeItems.map(item => ({
195
+ key: item.screen,
196
+ label: item.label,
197
+ detail: mode === 'compact' && width >= 52 ? item.detail : undefined
198
+ }));
199
+ }
200
+ if (screen === 'settings') {
201
+ const labels = settingsItems.map(item => settingLabel(item, updateCheck));
202
+ const labelWidth = pairedColumnWidth(labels, width, mode);
203
+ return settingsItems.map((item, index) => ({
204
+ key: item,
205
+ label: padDisplayEnd(truncate(settingLabel(item, updateCheck), labelWidth), labelWidth),
206
+ detail: settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck),
207
+ separator: ' ',
208
+ index
209
+ }));
210
+ }
211
+ if (screen === 'countries' || screen === 'map') {
212
+ const labels = countries.map(country => `${country.name} (${country.code})`);
213
+ const labelWidth = pairedColumnWidth(labels, width, mode);
214
+ return countries.map((country, index) => ({
215
+ key: country.code,
216
+ label: padDisplayEnd(truncate(`${country.name} (${country.code})`, labelWidth), labelWidth),
217
+ detail: country.stationCount.toLocaleString(),
218
+ separator: ' ',
219
+ index
220
+ }));
221
+ }
222
+ if (screen === 'airplay-settings') {
223
+ return airPlayDevices.map((device, index) => ({ key: device.id, label: device.name, detail: device.host, index }));
224
+ }
225
+ if (screen === 'help') {
226
+ const rows = [];
227
+ let index = 0;
228
+ for (const section of keyHelpSections) {
229
+ rows.push({ key: `section-${section.title}`, label: section.title, heading: true });
230
+ for (const entry of section.entries) {
231
+ rows.push({ key: `help-${index}`, label: entry.keys, detail: entry.description, index });
232
+ index += 1;
233
+ }
234
+ }
235
+ rows.push({ key: 'section-commands', label: 'Commands', heading: true });
236
+ for (const command of commandHelp) {
237
+ rows.push({ key: `command-${command.name}`, label: `:${command.name}${command.args ? ` ${command.args}` : ''}`, detail: command.description, index });
238
+ index += 1;
239
+ }
240
+ return rows;
241
+ }
242
+ return stations.map((station, index) => {
243
+ const favorite = favoriteKeys.has(`${station.provider}:${station.id}`);
244
+ const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
245
+ const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
246
+ return {
247
+ key: `${station.provider}:${station.id}`,
248
+ label: `${station.name}${favorite ? (ascii ? ' *' : ' ★') : ''}`,
249
+ detail: width >= 42 ? (index === selected ? selectedMetadata : standardMetadata) : undefined,
250
+ index
251
+ };
252
+ });
253
+ }
254
+ function pairedColumnWidth(labels, width, mode) {
255
+ const longest = Math.max(8, ...labels.map(displayWidth));
256
+ const available = Math.max(8, width - 4);
257
+ const cap = Math.max(8, Math.floor(available * (mode === 'micro' ? 0.5 : 0.52)));
258
+ return Math.min(longest, cap);
259
+ }
260
+ function adaptiveEmptyState(input) {
261
+ const { screen, searchQuery, editingSearch, countryFilter, loadingCountries, loadingStations, nearbyEnabled, stationTitle } = input;
262
+ if ((screen === 'countries' || screen === 'map') && loadingCountries) {
263
+ return [{ key: 'loading', label: 'Loading countries…' }];
264
+ }
265
+ if ((screen === 'countries' || screen === 'map') && countryFilter) {
266
+ return [{ key: 'empty', label: `No countries match “${countryFilter}”.` }, { key: 'hint', label: 'Press / to edit the filter.' }];
267
+ }
268
+ if (screen === 'search' && loadingStations) {
269
+ return [{ key: 'loading', label: 'Searching station directories…' }];
270
+ }
271
+ if (screen === 'search' && !searchQuery) {
272
+ return [
273
+ { key: 'empty', label: editingSearch ? 'Type a station, genre, or place.' : 'Press / to start a search.' },
274
+ { key: 'hint', label: 'Enter searches · Ctrl+↑ recalls history' }
275
+ ];
276
+ }
277
+ if (screen === 'search') {
278
+ return [{ key: 'empty', label: `No matches for “${searchQuery}”.` }, { key: 'hint', label: 'Try fewer words or clear filters.' }];
279
+ }
280
+ if (screen === 'nearby' && !nearbyEnabled) {
281
+ return [{ key: 'empty', label: 'Nearby lookup is off.' }, { key: 'hint', label: 'Press l to enable it.' }];
282
+ }
283
+ if (screen === 'library') {
284
+ return [{ key: 'empty', label: 'Your library is empty.' }, { key: 'hint', label: 'Press f on a station to save it.' }];
285
+ }
286
+ if (loadingStations) {
287
+ return [{ key: 'loading', label: 'Loading stations…' }];
288
+ }
289
+ if (screen === 'airplay-settings') {
290
+ return [{ key: 'empty', label: 'No AirPlay receivers found.' }, { key: 'hint', label: 'Press r to scan again.' }];
291
+ }
292
+ return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
293
+ }
294
+ function adaptiveStatus(props) {
295
+ const { screen, playback, playingStation, stations, countries, searchQuery, editingSearch, countryFilter, editingCountryFilter, library, filterLabel, selected } = props;
296
+ if (screen === 'home') {
297
+ return `${library.favorites.length} favorites · ${library.recent.length} recent · ${library.imported.length} imported`;
298
+ }
299
+ if (screen === 'now-playing') {
300
+ return `${playbackBackendLabel(playback.backend)} · ${playback.state} · vol ${playback.volume}`;
301
+ }
302
+ if (screen === 'settings') {
303
+ return `${settingsSectionFor(settingsItems[selected])} · Enter changes the selected setting`;
304
+ }
305
+ if (screen === 'search') {
306
+ const query = searchQuery || (editingSearch ? 'type to search' : 'press / to search');
307
+ if (props.mode === 'micro')
308
+ return query;
309
+ return `${editingSearch ? 'Editing' : 'Query'}: ${query}${filterLabel !== 'none' ? ` · ${filterLabel}` : ''}`;
310
+ }
311
+ if (screen === 'countries' || screen === 'map') {
312
+ return `${countries.length.toLocaleString()} countries${countryFilter ? ` · ${editingCountryFilter ? 'editing ' : ''}filter “${countryFilter}”` : ''}`;
313
+ }
314
+ if (screen === 'stats') {
315
+ return 'Stored locally on this machine';
316
+ }
317
+ if (screen === 'help') {
318
+ return 'Scroll for keys and commands';
319
+ }
320
+ if (screen === 'airplay-code') {
321
+ return playingStation ? playingStation.name : 'Receiver authentication';
322
+ }
323
+ if (screen === 'airplay-settings') {
324
+ return `${props.airPlayDevices.length} receivers · ${playbackBackendLabel(playback.backend)}`;
325
+ }
326
+ return `${stations.length.toLocaleString()} stations${playingStation ? ` · playing ${playingStation.name}` : ''}`;
327
+ }
328
+ function formatHours(hours) {
329
+ if (hours < 1)
330
+ return `${Math.round(hours * 60)}m`;
331
+ return `${hours.toFixed(hours >= 10 ? 0 : 1)}h`;
332
+ }