@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CODE_OF_CONDUCT.md +13 -0
  3. package/CONTRIBUTING.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +364 -0
  6. package/SECURITY.md +31 -0
  7. package/dist/activity/stats.js +122 -0
  8. package/dist/cli.js +143 -0
  9. package/dist/player/backend-install.js +122 -0
  10. package/dist/player/command.js +8 -0
  11. package/dist/player/player-controller.js +494 -0
  12. package/dist/playlists/playlist.js +169 -0
  13. package/dist/providers/cache.js +92 -0
  14. package/dist/providers/provider-manager.js +50 -0
  15. package/dist/providers/radio-browser.js +412 -0
  16. package/dist/providers/radio-garden.js +87 -0
  17. package/dist/storage/store.js +369 -0
  18. package/dist/types.js +55 -0
  19. package/dist/ui/App.js +770 -0
  20. package/dist/ui/AppContent.js +45 -0
  21. package/dist/ui/app-state.js +250 -0
  22. package/dist/ui/components/Logo.js +9 -0
  23. package/dist/ui/components/Menu.js +23 -0
  24. package/dist/ui/components/ScreenHeader.js +15 -0
  25. package/dist/ui/components/StationList.js +23 -0
  26. package/dist/ui/components/TopTabs.js +82 -0
  27. package/dist/ui/cosmo-land-data.js +4 -0
  28. package/dist/ui/cosmo-world-map.js +156 -0
  29. package/dist/ui/explore-map-layout.js +24 -0
  30. package/dist/ui/format.js +22 -0
  31. package/dist/ui/layout.js +27 -0
  32. package/dist/ui/list-window.js +8 -0
  33. package/dist/ui/page-footer.js +45 -0
  34. package/dist/ui/playback-footer.js +48 -0
  35. package/dist/ui/screen-items.js +26 -0
  36. package/dist/ui/screens/CountriesScreen.js +10 -0
  37. package/dist/ui/screens/ExploreScreen.js +44 -0
  38. package/dist/ui/screens/HomeScreen.js +9 -0
  39. package/dist/ui/screens/MapScreen.js +59 -0
  40. package/dist/ui/screens/NowPlayingScreen.js +79 -0
  41. package/dist/ui/screens/SearchScreen.js +12 -0
  42. package/dist/ui/screens/SettingsScreen.js +38 -0
  43. package/dist/ui/screens/StationScreen.js +7 -0
  44. package/dist/ui/screens/StatsScreen.js +90 -0
  45. package/dist/ui/terminal-mouse.js +38 -0
  46. package/dist/ui/theme.js +105 -0
  47. package/dist/ui/use-app-input.js +387 -0
  48. package/dist/ui/use-command-executor.js +156 -0
  49. package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
  50. package/dist/ui/world-map.js +274 -0
  51. package/docs/THIRD_PARTY_NOTICES.md +33 -0
  52. package/package.json +83 -0
@@ -0,0 +1,48 @@
1
+ import { truncate } from './format.js';
2
+ // Square braille spinner: a filled 2x4 dot cell that appears to rotate. Reads as
3
+ // a small spinning block in the terminal, which suits RadioCLI better than a
4
+ // thin circular spinner.
5
+ export const loadingSpinnerFrames = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
6
+ export function loadingSpinnerFrame(frame) {
7
+ const count = loadingSpinnerFrames.length;
8
+ // Guard against negative or fractional tick counters.
9
+ const index = ((Math.trunc(frame) % count) + count) % count;
10
+ return loadingSpinnerFrames[index] ?? loadingSpinnerFrames[0];
11
+ }
12
+ const visiblePlaybackStates = new Set(['loading', 'playing', 'paused']);
13
+ export function shouldShowPlaybackFooter(station, playback) {
14
+ // During loading the React-side station may not be set yet, but the player
15
+ // already knows the station name, so fall back to that.
16
+ return Boolean((station || playback.stationName) && visiblePlaybackStates.has(playback.state));
17
+ }
18
+ export function playbackFooterText({ station, playback, metadata, sleepLabel, width, spinnerFrame }) {
19
+ const stationName = station?.name ?? playback.stationName;
20
+ if (!stationName || !visiblePlaybackStates.has(playback.state)) {
21
+ return null;
22
+ }
23
+ const loading = playback.state === 'loading';
24
+ const nameLabel = loading
25
+ ? `${loadingSpinnerFrame(spinnerFrame ?? 0)} ${stationName}`
26
+ : stationName;
27
+ const details = [
28
+ statePrefix(playback.state),
29
+ nameLabel,
30
+ loading ? 'buffering…' : trackLabel(metadata, stationName),
31
+ playback.muted ? 'muted' : `vol ${playback.volume}`,
32
+ sleepLabel !== 'Sleep off' ? sleepLabel : undefined
33
+ ].filter(Boolean);
34
+ return truncate(details.join(' · '), Math.max(1, width));
35
+ }
36
+ function statePrefix(state) {
37
+ if (state === 'paused') {
38
+ return 'Paused';
39
+ }
40
+ return undefined;
41
+ }
42
+ function trackLabel(metadata, stationName) {
43
+ const title = metadata?.title?.trim();
44
+ if (!title || title.toLowerCase() === stationName.toLowerCase()) {
45
+ return undefined;
46
+ }
47
+ return title;
48
+ }
@@ -0,0 +1,26 @@
1
+ export const homeItems = [
2
+ { screen: 'now-playing', label: 'Playing', detail: 'Receiver display and controls' },
3
+ { screen: 'library', label: 'Library', detail: 'Favorites, recent stations, imported streams' },
4
+ { screen: 'explore', label: 'Explore', detail: 'Move a map cursor through geotagged stations' },
5
+ { screen: 'search', label: 'Search', detail: 'Find stations by name, genre, language, place' },
6
+ { screen: 'countries', label: 'Countries', detail: 'Browse by country list with a world-map toggle' },
7
+ { screen: 'nearby', label: 'Nearby', detail: 'Opt-in approximate location for local stations' },
8
+ { screen: 'stats', label: 'Stats', detail: 'Listening graph, stations, streaks, hours' },
9
+ { screen: 'settings', label: 'Settings', detail: 'Playback backend, colors, providers' }
10
+ ];
11
+ export const settingsItems = [
12
+ 'Cycle display color',
13
+ 'Cycle receiver style',
14
+ 'Toggle Radio Garden experimental adapter',
15
+ 'Toggle nearby location lookup',
16
+ 'Cycle playback backend',
17
+ 'Volume up',
18
+ 'Volume down',
19
+ 'Mute or unmute',
20
+ 'Toggle skip broken streams',
21
+ 'Refresh provider health',
22
+ 'Learn previous media key',
23
+ 'Learn play/pause media key',
24
+ 'Learn next media key',
25
+ 'Reset learned media keys'
26
+ ];
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { Menu, Pointer } from '../components/Menu.js';
4
+ import { ScreenHeader } from '../components/ScreenHeader.js';
5
+ import { themeAccent } from '../theme.js';
6
+ import { visibleWindow } from '../list-window.js';
7
+ export function CountriesScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, width }) {
8
+ const window = visibleWindow(countries, selected, pageSize);
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Countries", subtitle: editingFilter ? 'Filtering countries — type to narrow the list' : 'Browse the worldwide country directory', width: width, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: "Loading countries from Radio Browser\u2026" }) : null, !loading ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Showing ", countries.length ? window.start + 1 : 0, "-", window.end, " of ", countries.length] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.name }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", country.code, " \u00B7 ", country.stationCount.toLocaleString(), " stations"] })] })) })] })) : null] }));
10
+ }
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { StationList } from '../components/StationList.js';
5
+ import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
+ import { computeExploreMapLayout } from '../explore-map-layout.js';
7
+ import { ScreenHeader } from '../components/ScreenHeader.js';
8
+ import { exploreMapLand, mapMarker, panelBackground, panelBorder, themeAccent } from '../theme.js';
9
+ export function ExploreScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
10
+ const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
11
+ const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
12
+ const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
13
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: "single", borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: "single", borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: "gray", children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", children: loading ? 'Loading stations…' : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
14
+ }
15
+ function CosmoMapLine({ row, theme }) {
16
+ const chunks = [];
17
+ for (const cell of row.cells) {
18
+ const previous = chunks.at(-1);
19
+ if (previous?.kind === cell.kind) {
20
+ previous.text += cell.char;
21
+ }
22
+ else {
23
+ chunks.push({ kind: cell.kind, text: cell.char });
24
+ }
25
+ }
26
+ let offset = 0;
27
+ return (_jsx(Box, { children: chunks.map(chunk => {
28
+ const key = `${offset}-${chunk.kind}`;
29
+ offset += chunk.text.length;
30
+ return (_jsx(Text, { color: cosmoMapColor(chunk.kind, theme), children: chunk.text }, key));
31
+ }) }));
32
+ }
33
+ function cosmoMapColor(kind, theme) {
34
+ if (kind === 'selected') {
35
+ return themeAccent(theme);
36
+ }
37
+ if (kind === 'marker') {
38
+ return mapMarker;
39
+ }
40
+ if (kind === 'land') {
41
+ return exploreMapLand;
42
+ }
43
+ return undefined;
44
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { Logo } from '../components/Logo.js';
4
+ import { Menu, Pointer } from '../components/Menu.js';
5
+ import { themeAccent } from '../theme.js';
6
+ import { homeItems } from '../screen-items.js';
7
+ export function HomeScreen({ selected, theme, library, playback }) {
8
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsxs(Text, { children: ["Receiver:", ' ', _jsx(Text, { color: themeAccent(theme), children: playback.state === 'playing' ? playback.message ?? 'playing' : playback.state }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", playback.backend] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: "gray", children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
9
+ }
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { mapLand, mapWater, themeAccent, themeContributionColors } from '../theme.js';
4
+ import { visibleWindow } from '../list-window.js';
5
+ import { Menu, Pointer } from '../components/Menu.js';
6
+ import { ScreenHeader } from '../components/ScreenHeader.js';
7
+ import { truncate } from '../format.js';
8
+ import { buildWorldMap } from '../world-map.js';
9
+ export function MapScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, mode, width }) {
10
+ const contentWidth = Math.max(52, width - 2);
11
+ const topCountries = Array.from(countries).sort((a, b) => b.stationCount - a.stationCount).slice(0, mode === 'full' ? 10 : 6);
12
+ const selectedCountry = countries[selected];
13
+ const window = visibleWindow(countries, selected, pageSize);
14
+ const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
15
+ const topWidth = mode === 'full' ? 50 : contentWidth;
16
+ const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
17
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: `filter: ${filter || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: "Loading country density\u2026" }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsxs(Text, { color: "gray", children: [graph.plotted, " plotted \u00B7 ", graph.unplotted, " unplaced \u00B7 labels show highest station counts"] }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: "gray", children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? `${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}` : 'none' })] }), _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", truncate(country.name, Math.max(8, listWidth - 8))] })] })) })] })] })] }));
18
+ }
19
+ function MapLine({ row, theme }) {
20
+ const chunks = [];
21
+ for (const cell of row.cells) {
22
+ const previous = chunks[chunks.length - 1];
23
+ if (previous && previous.kind === cell.kind) {
24
+ previous.text += cell.char;
25
+ }
26
+ else {
27
+ chunks.push({ kind: cell.kind, text: cell.char });
28
+ }
29
+ }
30
+ const keyedChunks = [];
31
+ let offset = 0;
32
+ for (const chunk of chunks) {
33
+ keyedChunks.push({ key: `${offset}-${chunk.kind}`, ...chunk });
34
+ offset += chunk.text.length;
35
+ }
36
+ return (_jsx(Box, { children: keyedChunks.map(chunk => (_jsx(Text, { color: mapColor(chunk.kind, theme), children: chunk.text }, chunk.key))) }));
37
+ }
38
+ function mapColor(kind, theme) {
39
+ const colors = themeContributionColors(theme);
40
+ if (kind === 'selected') {
41
+ return themeAccent(theme);
42
+ }
43
+ if (kind === 'level4') {
44
+ return colors[4] ?? themeAccent(theme);
45
+ }
46
+ if (kind === 'level3') {
47
+ return colors[3] ?? themeAccent(theme);
48
+ }
49
+ if (kind === 'level2') {
50
+ return colors[2] ?? themeAccent(theme);
51
+ }
52
+ if (kind === 'level1') {
53
+ return colors[1] ?? 'gray';
54
+ }
55
+ if (kind === 'land') {
56
+ return mapLand;
57
+ }
58
+ return mapWater;
59
+ }
@@ -0,0 +1,79 @@
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 { panelBackground, themeAccent } from '../theme.js';
5
+ import { ScreenHeader } from '../components/ScreenHeader.js';
6
+ import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
7
+ export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, sleepLabel, showDiagnostics, stationTime, receiverStyle, width, height }) {
8
+ const accent = themeAccent(theme);
9
+ const panelWidth = Math.max(62, width);
10
+ const panelHeight = Math.max(10, height);
11
+ const innerWidth = Math.max(28, panelWidth - 6);
12
+ const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 14 : 9), innerWidth);
13
+ const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
14
+ const stationName = station ? truncate(station.name, innerWidth) : 'No station tuned';
15
+ const stationPlace = station
16
+ ? truncate(stationLocation(station).toUpperCase(), innerWidth)
17
+ : 'Choose a station from Library, Explore, Search, Countries, or Nearby.';
18
+ const infoFallback = diagnostics.availableBackends.length > 0
19
+ ? 'Playback backend ready. Choose a station to start tuning.'
20
+ : 'No playback backend found. Run radiocli doctor for setup help.';
21
+ // One compact meta line: stream tech, tags, and the sleep timer only when set.
22
+ const sleepSuffix = sleepLabel !== 'Sleep off' ? ` · ${sleepLabel}` : '';
23
+ const infoLine = station
24
+ ? truncate(`${stationTech(station)} · ${stationTags(station)}${sleepSuffix}`, innerWidth)
25
+ : infoFallback;
26
+ const metadataLine = metadata?.title
27
+ ? truncate(metadata.title, Math.max(8, innerWidth - 12))
28
+ : 'Waiting for ICY track metadata';
29
+ const dialLabel = receiverDialLabel(station);
30
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: "round", borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: dialLabel }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: stationName }) }), _jsx(Text, { color: "gray", children: truncate(stationPlace, innerWidth) }), _jsx(Box, { flexDirection: "column", children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
31
+ ? renderSegments(row.segments)
32
+ : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: metadataLine }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children: favorite ? '★ Favorite' : '☆ Favorite' })] }), _jsx(Text, { color: "gray", children: infoLine }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Diagnostics" }), _jsxs(Text, { color: "gray", children: ["Stream: ", diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'] }), _jsxs(Text, { color: "gray", children: ["Station time: ", stationTime] }), _jsxs(Text, { color: "gray", children: ["Started: ", diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing', " \u00B7 available ", diagnostics.availableBackends.join(', ') || 'none'] })] })) : null] })] }));
33
+ }
34
+ export function receiverDialLabel(station) {
35
+ if (!station) {
36
+ return 'FM ---';
37
+ }
38
+ const frequency = stationFrequency(station.name);
39
+ if (frequency) {
40
+ return frequency;
41
+ }
42
+ const codec = station.codec?.trim().toUpperCase();
43
+ if (station.bitrate && codec) {
44
+ return `FM ${String(station.bitrate).padStart(3, '0')}.${codec.slice(0, 1)}`;
45
+ }
46
+ if (station.bitrate) {
47
+ return `FM ${String(station.bitrate).padStart(3, '0')}`;
48
+ }
49
+ if (codec) {
50
+ return `FM ${codec}`;
51
+ }
52
+ return 'FM LIVE';
53
+ }
54
+ function stationFrequency(name) {
55
+ const frequencyMatch = name.match(/\b(?:(AM|FM)\s*)?(\d{2,4}(?:\.\d{1,2})?)(?:\s*(AM|FM))?\b/i);
56
+ if (!frequencyMatch) {
57
+ return null;
58
+ }
59
+ const value = Number(frequencyMatch[2]);
60
+ if (!Number.isFinite(value)) {
61
+ return null;
62
+ }
63
+ const explicitBand = (frequencyMatch[1] ?? frequencyMatch[3])?.toUpperCase();
64
+ if (explicitBand === 'AM' || (!explicitBand && value >= 520 && value <= 1710)) {
65
+ return `AM ${frequencyMatch[2]}`;
66
+ }
67
+ if (explicitBand === 'FM' || (!explicitBand && value >= 65 && value <= 120)) {
68
+ return `FM ${frequencyMatch[2]}`;
69
+ }
70
+ return null;
71
+ }
72
+ function renderSegments(segments) {
73
+ let offset = 0;
74
+ return segments.map(segment => {
75
+ const key = `${offset}-${segment.color}`;
76
+ offset += segment.text.length;
77
+ return (_jsx(Text, { color: segment.color, children: segment.text }, key));
78
+ });
79
+ }
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { StationList } from '../components/StationList.js';
4
+ import { ScreenHeader } from '../components/ScreenHeader.js';
5
+ import { panelBackground, panelBorder, themeAccent } from '../theme.js';
6
+ import { truncate } from '../format.js';
7
+ export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
8
+ const contentWidth = Math.max(40, width);
9
+ const inputWidth = Math.max(34, Math.min(contentWidth, 96));
10
+ const inputTextWidth = Math.max(8, inputWidth - 8);
11
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: "single", borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : 'gray', children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : 'gray', children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: "gray", children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
12
+ }
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { Menu, Pointer } from '../components/Menu.js';
4
+ import { ScreenHeader } from '../components/ScreenHeader.js';
5
+ import { truncate } from '../format.js';
6
+ import { settingsItems } from '../screen-items.js';
7
+ import { themeAccent } from '../theme.js';
8
+ export function SettingsScreen({ selected, settings, storePath, playback, backends, providerHealth, theme, diagnostics, width }) {
9
+ const accent = themeAccent(theme);
10
+ const lineWidth = Math.max(32, width - 4);
11
+ const health = Object.entries(providerHealth);
12
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: "Enter changes the highlighted setting \u00B7 shortcuts in the footer", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: settingsItems, selected: selected, keyFor: item => item, render: (item, _index, active) => {
13
+ const value = settingValue(item, settings, diagnostics, backends);
14
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: item }), value ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: accent, children: value })] })) : null] }));
15
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", bold: true, children: "Status" }), _jsxs(Text, { color: "gray", children: ["Player: ", _jsx(Text, { color: accent, children: playback.backend || 'none' }), " / ", playback.state, " \u00B7", ' ', diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`, " \u00B7 tune timeout ", settings.tuneTimeoutSeconds, "s"] }), _jsxs(Text, { color: "gray", children: ["Provider health: ", health.length ? health.map(([provider, status]) => `${provider} ${status}`).join(' · ') : 'not checked yet'] }), _jsxs(Text, { color: "gray", children: ["Active stream: ", truncate(diagnostics.streamUrl ?? 'none', lineWidth - 15)] }), _jsxs(Text, { color: "gray", children: ["Library: ", truncate(storePath, lineWidth - 9)] })] })] }));
16
+ }
17
+ function settingValue(item, settings, diagnostics, backends) {
18
+ switch (item) {
19
+ case 'Cycle display color':
20
+ return settings.theme;
21
+ case 'Cycle receiver style':
22
+ return settings.receiverStyle;
23
+ case 'Toggle Radio Garden experimental adapter':
24
+ return settings.enableRadioGarden ? 'on' : 'off';
25
+ case 'Toggle nearby location lookup':
26
+ return settings.enableNearbyLocation ? 'on' : 'off';
27
+ case 'Cycle playback backend':
28
+ return `${settings.preferredBackend} · available ${backends.length ? backends.join(', ') : 'none'}`;
29
+ case 'Mute or unmute':
30
+ return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
31
+ case 'Toggle skip broken streams':
32
+ return settings.skipBrokenStreams ? 'on' : 'off';
33
+ case 'Reset learned media keys':
34
+ return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
35
+ default:
36
+ return undefined;
37
+ }
38
+ }
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { StationList } from '../components/StationList.js';
4
+ import { ScreenHeader } from '../components/ScreenHeader.js';
5
+ export function StationScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, pageSize, width }) {
6
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: "gray", children: "Loading stations\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] })] }));
7
+ }
@@ -0,0 +1,90 @@
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 { panelBackground, panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
5
+ import { ScreenHeader } from '../components/ScreenHeader.js';
6
+ import { truncate } from '../format.js';
7
+ const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
8
+ export function StatsScreen({ library, theme, width, height }) {
9
+ const stats = computeListeningStats(library.activity.sessions);
10
+ const contentWidth = Math.max(20, width - 4);
11
+ const graph = buildContributionGraph(stats.days, contentWidth);
12
+ const graphColors = themeContributionColors(theme);
13
+ const favorite = stats.favoriteStation?.name ?? 'none yet';
14
+ const totalHours = stats.totalSeconds / 3600;
15
+ const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
16
+ const favoriteWidth = Math.max(8, metricWidth - 18);
17
+ const compact = height < 30;
18
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: "single", borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: "single", borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: ["\u2588\u2588", _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
19
+ ? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
20
+ : 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
21
+ }
22
+ function metricPair(leftLabel, leftValue, rightLabel, rightValue, metricWidth, theme) {
23
+ const accent = themeAccent(theme);
24
+ const leftPadding = Math.max(2, metricWidth - leftLabel.length - leftValue.length - 2);
25
+ return (_jsxs(Box, { height: 1, width: metricWidth * 2 + 2, children: [_jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [leftLabel, ": "] }), _jsx(Text, { color: accent, children: leftValue })] }) }), _jsx(Text, { children: ' '.repeat(leftPadding > 2 ? 2 : leftPadding) }), _jsx(Box, { width: metricWidth, children: _jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [rightLabel, ": "] }), _jsx(Text, { color: accent, children: rightValue })] }) })] }));
26
+ }
27
+ function buildContributionGraph(days, width) {
28
+ const cellWidth = width >= 168 ? 3 : width >= 120 ? 2 : 1;
29
+ const weeks = Array.from({ length: 53 }, (_, weekIndex) => days.slice(weekIndex * 7, weekIndex * 7 + 7)).filter(week => week.length > 0);
30
+ const maxSeconds = Math.max(1, ...days.map(day => day.seconds));
31
+ const labels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
32
+ const rows = Array.from({ length: 7 }, (_, dayIndex) => ({
33
+ key: `day-${dayIndex}`,
34
+ label: labels[dayIndex] ?? '',
35
+ cells: weeks.map(week => {
36
+ const day = week[dayIndex];
37
+ const level = contributionLevel(day?.seconds ?? 0, maxSeconds);
38
+ return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text: '█'.repeat(cellWidth) };
39
+ })
40
+ }));
41
+ return { months: monthLine(weeks, cellWidth), rows };
42
+ }
43
+ function contributionLevel(seconds, maxSeconds) {
44
+ if (seconds <= 0) {
45
+ return 0;
46
+ }
47
+ const ratio = seconds / maxSeconds;
48
+ if (ratio > 0.75) {
49
+ return 4;
50
+ }
51
+ if (ratio > 0.45) {
52
+ return 3;
53
+ }
54
+ if (ratio > 0.2) {
55
+ return 2;
56
+ }
57
+ return 1;
58
+ }
59
+ function monthLine(weeks, cellWidth) {
60
+ const cells = weeks.map((week, index) => {
61
+ const first = week[0];
62
+ if (!first) {
63
+ return ''.padEnd(cellWidth, ' ');
64
+ }
65
+ const date = parseLocalDay(first.date);
66
+ const previous = index > 0 && weeks[index - 1]?.[0]
67
+ ? parseLocalDay(weeks[index - 1][0].date)
68
+ : null;
69
+ const changedMonth = !previous || date.getMonth() !== previous.getMonth();
70
+ if (!changedMonth) {
71
+ return ''.padEnd(cellWidth, ' ');
72
+ }
73
+ const label = monthLabels[date.getMonth()];
74
+ return cellWidth >= 3 ? label : label.slice(0, cellWidth).padEnd(cellWidth, ' ');
75
+ });
76
+ return cells.join('');
77
+ }
78
+ function parseLocalDay(value) {
79
+ const [year = '0', month = '1', day = '1'] = value.split('-');
80
+ return new Date(Number(year), Number(month) - 1, Number(day));
81
+ }
82
+ function formatDays(days) {
83
+ return `${days} ${days === 1 ? 'day' : 'days'}`;
84
+ }
85
+ function formatHours(hours) {
86
+ if (hours < 10) {
87
+ return `${hours.toFixed(1)}h`;
88
+ }
89
+ return `${Math.round(hours).toLocaleString()}h`;
90
+ }
@@ -0,0 +1,38 @@
1
+ import { cosmoCoordinateForCell } from './cosmo-world-map.js';
2
+ import { computeExploreMapLayout } from './explore-map-layout.js';
3
+ export const enableMouseReporting = '\u001B[?1000h\u001B[?1006h';
4
+ export const disableMouseReporting = '\u001B[?1006l\u001B[?1000l';
5
+ const sgrMousePattern = /\u001B\[<(\d+);(\d+);(\d+)([Mm])/g;
6
+ export function parseSgrMouseEvents(input) {
7
+ return Array.from(input.matchAll(sgrMousePattern), match => ({
8
+ button: Number(match[1]),
9
+ x: Number(match[2]),
10
+ y: Number(match[3]),
11
+ pressed: match[4] === 'M'
12
+ })).filter(event => Number.isFinite(event.button) && Number.isFinite(event.x) && Number.isFinite(event.y));
13
+ }
14
+ export function primaryMousePress(events) {
15
+ return events.find(event => event.pressed && (event.button & 3) === 0 && (event.button & 96) === 0) ?? null;
16
+ }
17
+ export function exploreCursorForMouseCell(x, y, frameWidth, layout) {
18
+ if (layout.compact) {
19
+ return null;
20
+ }
21
+ const mapLayout = computeExploreMapLayout(frameWidth, layout.contentRows, layout.stationRows);
22
+ const contentLeft = 2;
23
+ const contentTop = layout.topRows + 1;
24
+ const mapOuterLeft = contentLeft;
25
+ const mapOuterTop = contentTop + mapLayout.headerRows + 1;
26
+ const mapInnerLeft = mapOuterLeft + 1;
27
+ const mapInnerTop = mapOuterTop + 1;
28
+ const col = Math.floor(x - mapInnerLeft);
29
+ const row = Math.floor(y - mapInnerTop);
30
+ if (col < 0 || col >= mapLayout.mapColumns || row < 0 || row >= mapLayout.mapRows) {
31
+ return null;
32
+ }
33
+ const coordinate = cosmoCoordinateForCell(col, row, mapLayout.mapColumns, mapLayout.mapRows);
34
+ return {
35
+ latitude: coordinate.lat,
36
+ longitude: coordinate.lon
37
+ };
38
+ }
@@ -0,0 +1,105 @@
1
+ import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
2
+ const receiverStyles = receiverStyleNames;
3
+ export const appBackground = '#070a0f';
4
+ export const panelBackground = '#090d14';
5
+ export const panelBorder = '#28313c';
6
+ // Shared neutral palette. Use these named tokens instead of ad-hoc hex literals
7
+ // so secondary text, rules, and map shading stay consistent across every screen.
8
+ export const textDim = '#5b6573';
9
+ export const textHighlight = '#d8c66f';
10
+ export const mapLand = '#33414f';
11
+ export const mapWater = '#1a2430';
12
+ export const mapMarker = '#7dd3fc';
13
+ export const exploreMapLand = '#a0a0c0';
14
+ export function themeAccent(theme) {
15
+ if (theme === 'amber') {
16
+ return '#ffb000';
17
+ }
18
+ if (theme === 'blue') {
19
+ return '#53a8ff';
20
+ }
21
+ if (theme === 'ruby') {
22
+ return '#ff5f87';
23
+ }
24
+ if (theme === 'ice') {
25
+ return '#b9f6ff';
26
+ }
27
+ if (theme === 'teal') {
28
+ return '#5eead4';
29
+ }
30
+ if (theme === 'violet') {
31
+ return '#a78bfa';
32
+ }
33
+ if (theme === 'copper') {
34
+ return '#d08770';
35
+ }
36
+ if (theme === 'cyan') {
37
+ return '#22d3ee';
38
+ }
39
+ if (theme === 'lime') {
40
+ return '#a3e635';
41
+ }
42
+ if (theme === 'coral') {
43
+ return '#ff7e6b';
44
+ }
45
+ if (theme === 'rose') {
46
+ return '#ff8fc7';
47
+ }
48
+ if (theme === 'slate') {
49
+ return '#9fb4cf';
50
+ }
51
+ if (theme === 'mono') {
52
+ return '#d0d0d0';
53
+ }
54
+ return '#74f28a';
55
+ }
56
+ export function themeContributionColors(theme) {
57
+ if (theme === 'amber') {
58
+ return ['#161b22', '#5f3700', '#9a6200', '#d68a00', '#ffb000'];
59
+ }
60
+ if (theme === 'blue') {
61
+ return ['#161b22', '#12385f', '#1f6feb', '#3388dd', '#53a8ff'];
62
+ }
63
+ if (theme === 'ruby') {
64
+ return ['#161b22', '#4c1230', '#8f274f', '#c93f68', '#ff5f87'];
65
+ }
66
+ if (theme === 'ice') {
67
+ return ['#161b22', '#24474d', '#4a95a0', '#86dce8', '#b9f6ff'];
68
+ }
69
+ if (theme === 'teal') {
70
+ return ['#161b22', '#123f3c', '#1f766c', '#2dd4bf', '#5eead4'];
71
+ }
72
+ if (theme === 'violet') {
73
+ return ['#161b22', '#302047', '#5b3f8f', '#7c5cff', '#a78bfa'];
74
+ }
75
+ if (theme === 'copper') {
76
+ return ['#161b22', '#44281f', '#7f4f37', '#b86f52', '#d08770'];
77
+ }
78
+ if (theme === 'cyan') {
79
+ return ['#161b22', '#0e3b42', '#168a9e', '#22b8cf', '#22d3ee'];
80
+ }
81
+ if (theme === 'lime') {
82
+ return ['#161b22', '#2f3d12', '#5f7d1f', '#84b32b', '#a3e635'];
83
+ }
84
+ if (theme === 'coral') {
85
+ return ['#161b22', '#4c2018', '#9a4032', '#d65c49', '#ff7e6b'];
86
+ }
87
+ if (theme === 'rose') {
88
+ return ['#161b22', '#4c2238', '#8f3f6a', '#d65c9a', '#ff8fc7'];
89
+ }
90
+ if (theme === 'slate') {
91
+ return ['#161b22', '#2a3340', '#4f6178', '#7c91ab', '#9fb4cf'];
92
+ }
93
+ if (theme === 'mono') {
94
+ return ['#161b22', '#3a3a3a', '#767676', '#b0b0b0', '#d0d0d0'];
95
+ }
96
+ return ['#161b22', '#0e4429', '#26a641', '#39d353', '#74f28a'];
97
+ }
98
+ export function nextTheme(theme) {
99
+ const index = themeNames.indexOf(theme);
100
+ return themeNames[(index + 1) % themeNames.length] ?? 'green';
101
+ }
102
+ export function nextReceiverStyle(style) {
103
+ const index = receiverStyles.indexOf(style);
104
+ return receiverStyles[(index + 1) % receiverStyles.length] ?? defaultReceiverStyle;
105
+ }