@ciphore/radiocli 0.1.7 → 0.1.9
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 +42 -0
- package/README.md +2 -2
- package/dist/activity/stats.js +4 -3
- package/dist/storage/store.js +7 -5
- package/dist/types.js +1 -1
- package/dist/ui/App.js +73 -11
- package/dist/ui/AppContent.js +2 -2
- package/dist/ui/app-state.js +21 -0
- package/dist/ui/components/Logo.js +2 -1
- package/dist/ui/components/Menu.js +2 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +4 -4
- package/dist/ui/components/TopTabs.js +3 -3
- package/dist/ui/layout.js +2 -1
- package/dist/ui/page-footer.js +5 -4
- package/dist/ui/screens/AirPlayCodeScreen.js +3 -3
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -4
- package/dist/ui/screens/CountriesScreen.js +8 -2
- package/dist/ui/screens/ExploreScreen.js +2 -2
- package/dist/ui/screens/HelpScreen.js +2 -2
- package/dist/ui/screens/HomeScreen.js +2 -2
- package/dist/ui/screens/MapScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +5 -5
- package/dist/ui/screens/SearchScreen.js +2 -2
- package/dist/ui/screens/SettingsScreen.js +3 -3
- package/dist/ui/screens/StationScreen.js +2 -1
- package/dist/ui/screens/StatsScreen.js +115 -29
- package/dist/ui/screens/screen-render.test.js +65 -3
- package/dist/ui/theme.js +6 -3
- package/dist/ui/use-app-input.js +6 -2
- package/dist/ui/visualizers/receiver-visualizers.js +94 -75
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.9] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Nearby station discovery is now enabled by default for new libraries, and the
|
|
15
|
+
`l` shortcut is limited to Overview, Nearby, and Settings so country lists
|
|
16
|
+
keep normal letter navigation.
|
|
17
|
+
- Favorite changes from Library now appear in the footer message row instead of
|
|
18
|
+
displacing the main page status.
|
|
19
|
+
- The listening stats activity graph now uses solid truecolor cell backgrounds
|
|
20
|
+
with tighter rendering on short terminals and fewer split color runs.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Nearby keeps showing the last loaded station list when location lookup is
|
|
25
|
+
turned off or temporarily unavailable.
|
|
26
|
+
- Long country names are truncated to one terminal row so dense country lists do
|
|
27
|
+
not wrap into nearby entries.
|
|
28
|
+
- Pressing `b` from a country station list now returns to Countries before
|
|
29
|
+
falling back to Overview.
|
|
30
|
+
|
|
31
|
+
## [0.1.8] - 2026-07-02
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Replaced the classic equalizer receiver style with Ultracode, a centered
|
|
36
|
+
color-ripple display that follows the active display color; saved `equalizer`
|
|
37
|
+
preferences migrate to `ultracode`.
|
|
38
|
+
- The listening stats activity graph now renders as a calendar-year grid with
|
|
39
|
+
larger contribution cells and clearer month labels when the terminal has room.
|
|
40
|
+
- Muted UI text now uses truecolor neutrals instead of terminal `gray`, making
|
|
41
|
+
secondary labels more readable across dark terminal palettes.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Stale continuous listening sessions are capped before splitting them across
|
|
46
|
+
days so one long-unclosed playback session cannot dominate listening totals.
|
|
47
|
+
- Receiver visualizer pulses now reset when an active Ultracode view opens but
|
|
48
|
+
keep advancing smoothly during normal playback.
|
|
49
|
+
|
|
10
50
|
## [0.1.7] - 2026-06-29
|
|
11
51
|
|
|
12
52
|
### Changed
|
|
@@ -158,6 +198,8 @@ Initial public release.
|
|
|
158
198
|
[0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
|
|
159
199
|
[0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
|
|
160
200
|
[0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
|
|
201
|
+
[0.1.8]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.8
|
|
202
|
+
[0.1.9]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.9
|
|
161
203
|
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
162
204
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
163
205
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -76,13 +76,13 @@ Live public radio from around the world
|
|
|
76
76
|
|
|
77
77
|
3 recent · 2 favorites · 1 imported
|
|
78
78
|
|
|
79
|
-
↑/↓ move · Enter open · number jump · : command
|
|
79
|
+
↑/↓ move · Enter open · number jump · l location · : command
|
|
80
80
|
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
|
|
84
84
|
|
|
85
|
-
- **Classic receiver** —
|
|
85
|
+
- **Classic receiver** — ultracode ripple, LEDs, and goniometer.
|
|
86
86
|
- **High-resolution braille** — smooth waveform, radial EQ, spectrogram, nebula, silk, ripple tank, phyllotaxis, harmonograph, bloom bars, moiré, galaxy, caustics.
|
|
87
87
|
- **Generative & motion** — matrix, hologram, spinning ASCII cube, generated fire, fireworks, plasma, spinning donut, starfield, Lorenz attractor, Barnsley fern, Chladni plate, rotating tesseract, torus knot, fractal tree, Julia sets, and lava-lamp motion.
|
|
88
88
|
|
package/dist/activity/stats.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stationKey } from '../storage/store.js';
|
|
2
2
|
const trackedDays = 371;
|
|
3
3
|
const listenedStationThresholdSeconds = 120;
|
|
4
|
+
const maxContinuousListeningSeconds = 12 * 60 * 60;
|
|
4
5
|
export function computeListeningStats(sessions, now = new Date()) {
|
|
5
6
|
const today = startOfLocalDay(now);
|
|
6
7
|
const firstDay = addLocalDays(today, -(trackedDays - 1));
|
|
@@ -49,9 +50,9 @@ function sessionSeconds(session, now = new Date()) {
|
|
|
49
50
|
const started = Date.parse(session.startedAt);
|
|
50
51
|
const ended = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
|
|
51
52
|
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended <= started) {
|
|
52
|
-
return Math.max(0, Math.round(session.listenedSeconds));
|
|
53
|
+
return Math.min(maxContinuousListeningSeconds, Math.max(0, Math.round(session.listenedSeconds)));
|
|
53
54
|
}
|
|
54
|
-
return Math.max(Math.round(session.listenedSeconds), Math.round((ended - started) / 1000));
|
|
55
|
+
return Math.min(maxContinuousListeningSeconds, Math.max(Math.round(session.listenedSeconds), Math.round((ended - started) / 1000)));
|
|
55
56
|
}
|
|
56
57
|
function localDay(date) {
|
|
57
58
|
const year = date.getFullYear();
|
|
@@ -68,7 +69,7 @@ function splitSessionByDay(session, seconds, firstDay, lastDayEnd, now) {
|
|
|
68
69
|
const rawEnd = Number.isFinite(recordedEnd) && recordedEnd > started
|
|
69
70
|
? recordedEnd
|
|
70
71
|
: started + seconds * 1000;
|
|
71
|
-
const end = Math.max(started, rawEnd);
|
|
72
|
+
const end = Math.max(started, Math.min(rawEnd, started + seconds * 1000));
|
|
72
73
|
const boundedStart = Math.max(started, firstDay.getTime());
|
|
73
74
|
const boundedEnd = Math.min(end, lastDayEnd.getTime());
|
|
74
75
|
if (boundedEnd <= boundedStart) {
|
package/dist/storage/store.js
CHANGED
|
@@ -28,8 +28,7 @@ const stationSchema = z
|
|
|
28
28
|
distanceKm: z.number().optional(),
|
|
29
29
|
hls: z.boolean().optional(),
|
|
30
30
|
lastCheckedOk: z.boolean().optional()
|
|
31
|
-
})
|
|
32
|
-
.strict();
|
|
31
|
+
});
|
|
33
32
|
const defaultMediaKeys = {
|
|
34
33
|
previous: [],
|
|
35
34
|
playPause: [],
|
|
@@ -127,6 +126,9 @@ const removedReceiverStyles = new Set([
|
|
|
127
126
|
'voronoi'
|
|
128
127
|
]);
|
|
129
128
|
function normalizeReceiverStyle(value) {
|
|
129
|
+
if (value === 'equalizer') {
|
|
130
|
+
return 'ultracode';
|
|
131
|
+
}
|
|
130
132
|
return typeof value === 'string' && removedReceiverStyles.has(value) ? defaultReceiverStyle : value;
|
|
131
133
|
}
|
|
132
134
|
const settingsSchema = z.object({
|
|
@@ -135,7 +137,7 @@ const settingsSchema = z.object({
|
|
|
135
137
|
receiverStyleVersion: z.number().optional(),
|
|
136
138
|
volume: z.number().min(0).max(100).default(70),
|
|
137
139
|
enableRadioGarden: z.boolean().default(false),
|
|
138
|
-
enableNearbyLocation: z.boolean().default(
|
|
140
|
+
enableNearbyLocation: z.boolean().default(true),
|
|
139
141
|
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
|
|
140
142
|
preferredAirPlayDevice: z.string().min(1).optional(),
|
|
141
143
|
tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
|
|
@@ -189,7 +191,7 @@ const librarySchema = z.object({
|
|
|
189
191
|
receiverStyleVersion: 2,
|
|
190
192
|
volume: 70,
|
|
191
193
|
enableRadioGarden: false,
|
|
192
|
-
enableNearbyLocation:
|
|
194
|
+
enableNearbyLocation: true,
|
|
193
195
|
preferredBackend: 'auto',
|
|
194
196
|
tuneTimeoutSeconds: 12,
|
|
195
197
|
skipBrokenStreams: true,
|
|
@@ -384,7 +386,7 @@ function defaultState() {
|
|
|
384
386
|
receiverStyleVersion: 2,
|
|
385
387
|
volume: 70,
|
|
386
388
|
enableRadioGarden: false,
|
|
387
|
-
enableNearbyLocation:
|
|
389
|
+
enableNearbyLocation: true,
|
|
388
390
|
preferredBackend: 'auto',
|
|
389
391
|
tuneTimeoutSeconds: 12,
|
|
390
392
|
skipBrokenStreams: true,
|
package/dist/types.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
|
|
2
2
|
export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
|
|
3
3
|
export const receiverStyleNames = [
|
|
4
|
-
'
|
|
4
|
+
'ultracode',
|
|
5
5
|
'motion-blob',
|
|
6
6
|
'motion-area',
|
|
7
7
|
'motion-contour',
|
package/dist/ui/App.js
CHANGED
|
@@ -6,7 +6,7 @@ import { PlayerController } from '../player/player-controller.js';
|
|
|
6
6
|
import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
7
7
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
8
8
|
import { receiverStyleNames } from '../types.js';
|
|
9
|
-
import { nextReceiverStyle, nextTheme, textDim, themeAccent } from './theme.js';
|
|
9
|
+
import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
|
|
10
10
|
import { DisplayContext, resolveDisplayMode } from './display-context.js';
|
|
11
11
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
12
12
|
import { AppContent } from './AppContent.js';
|
|
@@ -22,11 +22,12 @@ import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
|
|
|
22
22
|
import { isAirPlayBackendAvailable } from './airplay-settings.js';
|
|
23
23
|
import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
24
24
|
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
25
|
-
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
25
|
+
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextReceiverPulse, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldResetReceiverPulse, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
26
26
|
const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
|
|
27
27
|
const LIVE_RECEIVER_PULSE_MS = 80;
|
|
28
28
|
const AMBIENT_RECEIVER_PULSE_MS = 140;
|
|
29
29
|
const LOADING_SPINNER_MS = 120;
|
|
30
|
+
const VISUALIZER_MESSAGE_MS = 4500;
|
|
30
31
|
const settingToggleLabel = {
|
|
31
32
|
resumeOnLaunch: 'Resume on launch',
|
|
32
33
|
transparentBackground: 'Transparent background',
|
|
@@ -50,6 +51,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
50
51
|
const [screen, setScreen] = useState('home');
|
|
51
52
|
const [selected, setSelected] = useState(0);
|
|
52
53
|
const [message, setMessage] = useState(null);
|
|
54
|
+
const [footerMessage, setFooterMessage] = useState(null);
|
|
53
55
|
const [countries, setCountries] = useState([]);
|
|
54
56
|
const [countryFilter, setCountryFilter] = useState('');
|
|
55
57
|
const [editingCountryFilter, setEditingCountryFilter] = useState(false);
|
|
@@ -86,6 +88,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
86
88
|
const exploreCursorRef = useRef(exploreCursor);
|
|
87
89
|
const exploreRequestRef = useRef(0);
|
|
88
90
|
const exploreMoveTimerRef = useRef(null);
|
|
91
|
+
const transientMessageTimerRef = useRef(null);
|
|
92
|
+
const transientFooterMessageTimerRef = useRef(null);
|
|
93
|
+
const receiverPulseSnapshotRef = useRef(null);
|
|
89
94
|
const theme = library.settings.theme;
|
|
90
95
|
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
91
96
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
@@ -157,10 +162,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
157
162
|
displayStationsRef.current = displayStations;
|
|
158
163
|
const sleepLabel = sleepUntil ? `Sleep ${formatTimeLeft(sleepUntil - Date.now())}` : 'Sleep off';
|
|
159
164
|
const showPlaybackFooter = shouldShowPlaybackFooter(playingStation, playback);
|
|
165
|
+
const footerRows = showPlaybackFooter ? 4 : 3;
|
|
160
166
|
const selectedAirPlayDevice = useMemo(() => availableAirPlayDevices.find(device => device.id === library.settings.preferredAirPlayDevice), [availableAirPlayDevices, library.settings.preferredAirPlayDevice]);
|
|
161
167
|
const canEnterAirPlayCode = isAirPlayCodePromptActive(playback) ||
|
|
162
168
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
163
|
-
const layout = computeTerminalLayout(columns, rows,
|
|
169
|
+
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
164
170
|
const frameWidth = Math.max(40, layout.columns - 2);
|
|
165
171
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
166
172
|
const playingStationRef = useRef(null);
|
|
@@ -187,6 +193,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
187
193
|
lastStationContextKeyRef.current = renderedStationContextKey;
|
|
188
194
|
}
|
|
189
195
|
}, [renderedStationContextKey, screen, selected]);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
const current = {
|
|
198
|
+
screen,
|
|
199
|
+
receiverStyle: library.settings.receiverStyle,
|
|
200
|
+
playbackState: playback.state,
|
|
201
|
+
playbackReady: playback.ready
|
|
202
|
+
};
|
|
203
|
+
if (shouldResetReceiverPulse(receiverPulseSnapshotRef.current, current)) {
|
|
204
|
+
setPulse(0);
|
|
205
|
+
}
|
|
206
|
+
receiverPulseSnapshotRef.current = current;
|
|
207
|
+
}, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
|
|
190
208
|
useEffect(() => {
|
|
191
209
|
if (!shouldAnimateReceiver(screen, playback) ||
|
|
192
210
|
library.settings.reduceMotion ||
|
|
@@ -195,7 +213,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
195
213
|
return;
|
|
196
214
|
}
|
|
197
215
|
const intervalMs = LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle) ? LIVE_RECEIVER_PULSE_MS : AMBIENT_RECEIVER_PULSE_MS;
|
|
198
|
-
const timer = setInterval(() => setPulse(
|
|
216
|
+
const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
|
|
199
217
|
return () => clearInterval(timer);
|
|
200
218
|
}, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
|
|
201
219
|
useEffect(() => {
|
|
@@ -223,6 +241,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
223
241
|
if (exploreMoveTimerRef.current) {
|
|
224
242
|
clearTimeout(exploreMoveTimerRef.current);
|
|
225
243
|
}
|
|
244
|
+
if (transientMessageTimerRef.current) {
|
|
245
|
+
clearTimeout(transientMessageTimerRef.current);
|
|
246
|
+
}
|
|
247
|
+
if (transientFooterMessageTimerRef.current) {
|
|
248
|
+
clearTimeout(transientFooterMessageTimerRef.current);
|
|
249
|
+
}
|
|
226
250
|
}, []);
|
|
227
251
|
useEffect(() => {
|
|
228
252
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
@@ -303,6 +327,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
303
327
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
304
328
|
const remembered = selectedByScreenRef.current[next] ?? 0;
|
|
305
329
|
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
330
|
+
if (next === 'now-playing' && screenRef.current !== 'now-playing') {
|
|
331
|
+
setPulse(0);
|
|
332
|
+
}
|
|
306
333
|
setScreen(next);
|
|
307
334
|
setSelected(clamp(nextSelection, (itemCountsRef.current[next] ?? 0) - 1));
|
|
308
335
|
if (options.clearMessage !== false) {
|
|
@@ -482,6 +509,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
482
509
|
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
483
510
|
try {
|
|
484
511
|
if (!settingsRef.current.enableNearbyLocation) {
|
|
512
|
+
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
513
|
+
setMessage('Nearby location lookup is off. Showing the last nearby station list.');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
485
516
|
setStationContextFor('nearby', {
|
|
486
517
|
title: 'Nearby',
|
|
487
518
|
subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
|
|
@@ -492,6 +523,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
492
523
|
const detected = location ?? (await providers.detectLocation());
|
|
493
524
|
setLocation(detected);
|
|
494
525
|
if (!detected) {
|
|
526
|
+
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
527
|
+
setMessage('Location detection is unavailable. Showing the last nearby station list.');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
495
530
|
setStationContextFor('nearby', {
|
|
496
531
|
title: 'Nearby',
|
|
497
532
|
subtitle: 'Location detection was unavailable',
|
|
@@ -599,6 +634,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
599
634
|
}
|
|
600
635
|
}, [playStation, store]);
|
|
601
636
|
playStationRef.current = playStation;
|
|
637
|
+
const showTransientFooterMessage = useCallback((nextMessage) => {
|
|
638
|
+
if (transientFooterMessageTimerRef.current) {
|
|
639
|
+
clearTimeout(transientFooterMessageTimerRef.current);
|
|
640
|
+
}
|
|
641
|
+
setFooterMessage(nextMessage);
|
|
642
|
+
transientFooterMessageTimerRef.current = setTimeout(() => {
|
|
643
|
+
setFooterMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
644
|
+
transientFooterMessageTimerRef.current = null;
|
|
645
|
+
}, VISUALIZER_MESSAGE_MS);
|
|
646
|
+
}, []);
|
|
602
647
|
const toggleFavorite = useCallback((station) => {
|
|
603
648
|
if (!station) {
|
|
604
649
|
setMessage('Select or play a station before pressing f.');
|
|
@@ -606,12 +651,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
606
651
|
}
|
|
607
652
|
const wasFavorite = store.isFavorite(station);
|
|
608
653
|
setLibrary(store.toggleFavorite(station));
|
|
609
|
-
|
|
654
|
+
const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
|
|
655
|
+
if (screenRef.current === 'library') {
|
|
656
|
+
showTransientFooterMessage(favoriteMessage);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
setMessage(favoriteMessage);
|
|
660
|
+
}
|
|
610
661
|
if (!wasFavorite) {
|
|
611
662
|
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
612
663
|
void providers.vote(station);
|
|
613
664
|
}
|
|
614
|
-
}, [providers, store]);
|
|
665
|
+
}, [providers, showTransientFooterMessage, store]);
|
|
615
666
|
const showControlResult = useCallback((result) => {
|
|
616
667
|
if (!result.ok && result.message) {
|
|
617
668
|
setMessage(result.message);
|
|
@@ -669,16 +720,27 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
669
720
|
const togglePause = useCallback(() => {
|
|
670
721
|
void player.togglePause().then(showControlResult);
|
|
671
722
|
}, [player, showControlResult]);
|
|
723
|
+
const showTransientMessage = useCallback((nextMessage) => {
|
|
724
|
+
if (transientMessageTimerRef.current) {
|
|
725
|
+
clearTimeout(transientMessageTimerRef.current);
|
|
726
|
+
}
|
|
727
|
+
setMessage(nextMessage);
|
|
728
|
+
transientMessageTimerRef.current = setTimeout(() => {
|
|
729
|
+
setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
730
|
+
transientMessageTimerRef.current = null;
|
|
731
|
+
}, VISUALIZER_MESSAGE_MS);
|
|
732
|
+
}, []);
|
|
672
733
|
const cycleDisplayColor = useCallback(() => {
|
|
673
734
|
const theme = nextTheme(settingsRef.current.theme);
|
|
674
735
|
updateSettings({ theme });
|
|
675
|
-
|
|
676
|
-
}, [updateSettings]);
|
|
736
|
+
showTransientMessage(`Display color: ${theme}`);
|
|
737
|
+
}, [showTransientMessage, updateSettings]);
|
|
677
738
|
const cycleReceiverStyle = useCallback(() => {
|
|
678
739
|
const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
|
|
740
|
+
setPulse(0);
|
|
679
741
|
updateSettings({ receiverStyle });
|
|
680
|
-
|
|
681
|
-
}, [updateSettings]);
|
|
742
|
+
showTransientMessage(`Receiver style: ${receiverStyle}`);
|
|
743
|
+
}, [showTransientMessage, updateSettings]);
|
|
682
744
|
const toggleRadioGarden = useCallback(() => {
|
|
683
745
|
const enableRadioGarden = !settingsRef.current.enableRadioGarden;
|
|
684
746
|
updateSettings({ enableRadioGarden });
|
|
@@ -987,7 +1049,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
987
1049
|
playbackBackend: playback.backend,
|
|
988
1050
|
screen
|
|
989
1051
|
});
|
|
990
|
-
return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }),
|
|
1052
|
+
return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), _jsx(Box, { height: 1, children: message ? _jsx(Text, { color: themeAccent(theme), children: truncate(message, frameWidth) }) : null })] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(Text, { color: themeAccent(theme), children: footerMessage ? truncate(footerMessage, frameWidth) : ' ' }), playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }) }));
|
|
991
1053
|
}
|
|
992
1054
|
function buildLibraryStations(library) {
|
|
993
1055
|
const stations = [];
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import { themeAccent } from './theme.js';
|
|
3
|
+
import { textMuted, themeAccent } from './theme.js';
|
|
4
4
|
import { HomeScreen } from './screens/HomeScreen.js';
|
|
5
5
|
import { CountriesScreen } from './screens/CountriesScreen.js';
|
|
6
6
|
import { MapScreen } from './screens/MapScreen.js';
|
|
@@ -17,7 +17,7 @@ import { selectedAirPlayDevice } from './airplay-settings.js';
|
|
|
17
17
|
import { playbackBackendLabel } from '../player/backend-install.js';
|
|
18
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 }) {
|
|
19
19
|
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:
|
|
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" })] }));
|
|
21
21
|
}
|
|
22
22
|
if (screen === 'home') {
|
|
23
23
|
return _jsx(HomeScreen, { selected: selected, theme: theme, library: library, playback: playback });
|
package/dist/ui/app-state.js
CHANGED
|
@@ -173,6 +173,27 @@ export function stationContextKeyForScreen(screen) {
|
|
|
173
173
|
export function shouldAnimateReceiver(screen, playback) {
|
|
174
174
|
return screen === 'now-playing' && playback.state === 'playing' && playback.ready;
|
|
175
175
|
}
|
|
176
|
+
export function shouldToggleNearbyLocationShortcut(input, screen) {
|
|
177
|
+
return input === 'l' && (screen === 'home' || screen === 'nearby' || screen === 'settings');
|
|
178
|
+
}
|
|
179
|
+
export function nextReceiverPulse(pulse) {
|
|
180
|
+
return pulse + 1;
|
|
181
|
+
}
|
|
182
|
+
export function shouldResetReceiverPulse(previous, current) {
|
|
183
|
+
if (current.receiverStyle !== 'ultracode') {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const currentActive = current.screen === 'now-playing' &&
|
|
187
|
+
current.playbackState === 'playing' &&
|
|
188
|
+
current.playbackReady;
|
|
189
|
+
if (!currentActive) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return !(previous?.screen === 'now-playing' &&
|
|
193
|
+
previous.receiverStyle === 'ultracode' &&
|
|
194
|
+
previous.playbackState === 'playing' &&
|
|
195
|
+
previous.playbackReady);
|
|
196
|
+
}
|
|
176
197
|
export function isEditableInput(input, key) {
|
|
177
198
|
return Boolean(key.backspace || key.delete || input);
|
|
178
199
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { textMuted } from '../theme.js';
|
|
3
4
|
const logoSpectrumColors = ['#ff4b5c', '#ff9f43', '#ffd166', '#a3e635', '#22c55e', '#2dd4bf', '#38bdf8', '#818cf8', '#c084fc'];
|
|
4
5
|
function LogoSpectrum() {
|
|
5
6
|
return (_jsx(_Fragment, { children: logoSpectrumColors.map(color => (_jsx(Text, { color: color, children: "\u2588\u2588" }, color))) }));
|
|
6
7
|
}
|
|
7
8
|
export function Logo() {
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "RADIOCLI" }), _jsx(Text, { children: " " }), _jsx(LogoSpectrum, {})] }), _jsx(Text, { color:
|
|
9
|
+
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" })] }));
|
|
9
10
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
export function Menu({ items, selected, keyFor, render }) {
|
|
4
|
-
|
|
4
|
+
const activeIndex = items.length > 0 ? Math.min(Math.max(selected, 0), items.length - 1) : -1;
|
|
5
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, position) => (_jsx(Box, { children: render(item, position, position === activeIndex) }, keyFor?.(item, position) ?? defaultMenuKey(item)))) }));
|
|
5
6
|
}
|
|
6
7
|
export function Pointer({ active }) {
|
|
7
8
|
return _jsx(Text, { bold: active, children: active ? '> ' : ' ' });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import { textDim, themeAccent } from '../theme.js';
|
|
3
|
+
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
4
4
|
import { useDisplay } from '../display-context.js';
|
|
5
5
|
import { toAsciiSafe } from '../ascii.js';
|
|
6
6
|
import { truncate } from '../format.js';
|
|
@@ -15,5 +15,5 @@ export function ScreenHeader({ title, width, theme, subtitle, right }) {
|
|
|
15
15
|
const rightText = right ? ` ${right}` : '';
|
|
16
16
|
const ruleWidth = Math.max(1, width - safeTitle.length - rightText.length - 2);
|
|
17
17
|
const rule = (ascii ? '-' : '─').repeat(ruleWidth);
|
|
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:
|
|
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,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
|
|
4
|
-
import { themeAccent } from '../theme.js';
|
|
4
|
+
import { textMuted, themeAccent } from '../theme.js';
|
|
5
5
|
import { Menu, Pointer } from './Menu.js';
|
|
6
6
|
import { visibleWindow } from '../list-window.js';
|
|
7
7
|
export function StationList({ stations, selected, theme, favorites, pageSize, width }) {
|
|
8
8
|
if (stations.length === 0) {
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color:
|
|
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." })] }));
|
|
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:
|
|
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) => {
|
|
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
19
|
const titleUsed = stationName.length + (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:
|
|
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: _jsxs(Text, { color: textMuted, children: ["#", window.start + index + 1, " \u00B7 ", truncate(stationTags(station), rowWidth - 8)] }) })) : null] }));
|
|
22
22
|
} })] }));
|
|
23
23
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import { panelBorder, themeAccent } from '../theme.js';
|
|
3
|
+
import { panelBorder, textMuted, themeAccent } from '../theme.js';
|
|
4
4
|
import { useDisplay } from '../display-context.js';
|
|
5
5
|
import { truncate } from '../format.js';
|
|
6
6
|
export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
7
7
|
const accent = themeAccent(theme);
|
|
8
|
-
const {
|
|
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: '│' };
|
|
@@ -20,7 +20,7 @@ export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
|
20
20
|
const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
|
|
21
21
|
const visibleTabsWidth = tabsWidth(visibleTabs);
|
|
22
22
|
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
|
|
23
|
-
return (_jsxs(Box, { flexDirection: "column", backgroundColor:
|
|
23
|
+
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
24
|
}
|
|
25
25
|
function fitTabs(tabs, active, maxWidth) {
|
|
26
26
|
const all = tabs.map(tab => ({ type: 'tab', tab }));
|
package/dist/ui/layout.js
CHANGED
|
@@ -7,6 +7,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
|
|
|
7
7
|
const contentRows = Math.max(1, safeRows - reservedFooterRows - topRows);
|
|
8
8
|
const mapMode = safeColumns >= 88 && contentRows >= 24 ? 'full' : 'compact';
|
|
9
9
|
const stationRows = clamp(contentRows - 6, 1, 48);
|
|
10
|
+
const countryRows = clamp(contentRows - 5, 1, 64);
|
|
10
11
|
return {
|
|
11
12
|
columns: safeColumns,
|
|
12
13
|
rows: safeRows,
|
|
@@ -14,7 +15,7 @@ export function computeTerminalLayout(columns = 100, rows = 30, footerRows = 2)
|
|
|
14
15
|
topRows,
|
|
15
16
|
contentRows,
|
|
16
17
|
stationRows: compact ? 0 : stationRows,
|
|
17
|
-
countryRows: compact ? 0 :
|
|
18
|
+
countryRows: compact ? 0 : countryRows,
|
|
18
19
|
mapCountryRows: compact ? 0 : Math.max(1, contentRows - (mapMode === 'full' ? 25 : 14)),
|
|
19
20
|
mapMode,
|
|
20
21
|
receiverWidth: compact ? safeColumns : Math.max(62, safeColumns - 4),
|
package/dist/ui/page-footer.js
CHANGED
|
@@ -7,7 +7,7 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
7
7
|
return `COMMAND :${commandText}`;
|
|
8
8
|
}
|
|
9
9
|
if (screen === 'home') {
|
|
10
|
-
return '↑/↓ move · Enter open · number jump · : command';
|
|
10
|
+
return '↑/↓ move · Enter open · number jump · l location · : command';
|
|
11
11
|
}
|
|
12
12
|
if (screen === 'search' && editingSearch) {
|
|
13
13
|
return 'Type query · ↑/↓ move results · Ctrl+↑/↓ history · Enter search/tune · Esc finish';
|
|
@@ -27,9 +27,10 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
|
|
|
27
27
|
if (screen === 'explore') {
|
|
28
28
|
return 'Click map · WASD fine move · Shift+WASD jump · ↑/↓ station · Enter tune · f favorite · b home';
|
|
29
29
|
}
|
|
30
|
-
if (screen === 'nearby'
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (screen === 'nearby') {
|
|
31
|
+
return '↑/↓ or n/p move · Enter tune · f favorite · l location · [/] page · b home';
|
|
32
|
+
}
|
|
33
|
+
if (screen === 'stations' || screen === 'library') {
|
|
33
34
|
return '↑/↓ or n/p move · Enter tune · f favorite · [/] page · b home';
|
|
34
35
|
}
|
|
35
36
|
if (screen === 'now-playing') {
|
|
@@ -2,16 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
4
4
|
import { truncate } from '../format.js';
|
|
5
|
-
import { textDim, themeAccent } from '../theme.js';
|
|
5
|
+
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
6
6
|
export function AirPlayCodeScreen({ code, playback, selectedDevice, theme, width }) {
|
|
7
7
|
const accent = themeAccent(theme);
|
|
8
8
|
const lineWidth = Math.max(24, width - 4);
|
|
9
9
|
const promptActive = isAirPlayCodePromptActive(playback);
|
|
10
10
|
const receiverName = playback.airPlayDeviceName ?? selectedDevice?.name ?? 'selected receiver';
|
|
11
11
|
const masked = code.length > 0 ? '*'.repeat(code.length) : '';
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color:
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay Code", subtitle: "Enter the code shown on the receiver", right: promptActive ? 'Waiting for code' : 'Not requested', width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Receiver: ", _jsx(Text, { color: accent, children: truncate(receiverName, Math.max(8, lineWidth - 10)) })] }), _jsxs(Text, { color: textMuted, children: ["Playback: ", _jsx(Text, { color: accent, children: playback.backend }), " / ", playback.state] }), _jsx(Text, { color: promptActive ? accent : 'yellow', children: promptActive
|
|
13
13
|
? 'The receiver is asking for a code now.'
|
|
14
|
-
: 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color:
|
|
14
|
+
: 'Tune a station with AirPlay first; this screen is used once the receiver asks for a code.' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Code" }), _jsx(Text, { color: code ? accent : textDim, children: code ? masked : 'type the code from the receiver' }), _jsx(Text, { color: textDim, children: "Enter submits \u00B7 Backspace edits \u00B7 Esc returns to AirPlay" })] })] }));
|
|
15
15
|
}
|
|
16
16
|
export function isAirPlayCodePromptActive(playback) {
|
|
17
17
|
return playback.backend === 'airplay' && playback.message === 'AirPlay code required. Use :airplay-code 1234.';
|
|
@@ -5,7 +5,7 @@ import { audioOutputLabel } from '../audio-output.js';
|
|
|
5
5
|
import { Menu, Pointer } from '../components/Menu.js';
|
|
6
6
|
import { ScreenHeader } from '../components/ScreenHeader.js';
|
|
7
7
|
import { truncate } from '../format.js';
|
|
8
|
-
import { textDim, themeAccent } from '../theme.js';
|
|
8
|
+
import { textDim, textMuted, themeAccent } from '../theme.js';
|
|
9
9
|
export function AirPlaySettingsScreen({ selected, settings, backends, devices, theme, width }) {
|
|
10
10
|
const accent = themeAccent(theme);
|
|
11
11
|
const availability = airPlayAvailability(backends, devices);
|
|
@@ -14,11 +14,11 @@ export function AirPlaySettingsScreen({ selected, settings, backends, devices, t
|
|
|
14
14
|
const canEnterCode = availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local;
|
|
15
15
|
const lineWidth = Math.max(24, width - 4);
|
|
16
16
|
const preferredOutput = audioOutputLabel(settings.preferredBackend);
|
|
17
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color:
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "AirPlay", subtitle: "Choose where AirPlay playback should go", right: availability.label, width: width, theme: theme }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: ["Audio output: ", _jsx(Text, { color: accent, children: preferredOutput }), " \u00B7 Streaming:", ' ', _jsx(Text, { color: availability.ready ? accent : 'yellow', children: availability.ready ? 'ready' : 'unavailable' }), " \u00B7 Receivers:", ' ', _jsx(Text, { color: accent, children: devices.length })] }), _jsx(Text, { color: availability.ready ? textMuted : 'yellow', children: truncate(availability.detail, lineWidth) }), _jsxs(Text, { color: textMuted, children: ["Selected: ", _jsx(Text, { color: selectedDevice ? accent : textDim, children: truncate(selectedDevice?.name ?? 'none', Math.max(4, lineWidth - 10)) })] }), availability.ready && selectedDevice?.requiresPassword && !selectedDevice.local ? (_jsxs(Text, { color: textMuted, children: ["Code: ", _jsx(Text, { color: accent, children: "if the receiver shows a code while tuning, RadioCLI will ask for it" })] })) : null, selectedMissing ? (_jsxs(Text, { color: "yellow", children: ["Saved receiver is not visible: ", truncate(settings.preferredAirPlayDevice ?? '', Math.max(8, lineWidth - 31))] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Receivers" }), devices.length > 0 ? (_jsx(Menu, { items: devices, selected: selected, keyFor: device => device.id, render: (device, _index, active) => {
|
|
18
18
|
const isSelected = device.id === settings.preferredAirPlayDevice;
|
|
19
19
|
const name = truncate(device.name, Math.max(8, Math.floor(lineWidth * 0.35)));
|
|
20
20
|
const selectedLabel = isSelected ? ' selected' : '';
|
|
21
21
|
const detail = truncate(airPlayDeviceDetail(device), Math.max(12, lineWidth - name.length - selectedLabel.length - 5));
|
|
22
|
-
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color:
|
|
23
|
-
} })) : (_jsx(Text, { color:
|
|
22
|
+
return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? accent : undefined, bold: active, children: name }), isSelected ? _jsx(Text, { color: accent, children: selectedLabel }) : null, _jsxs(Text, { color: textMuted, children: [" \u00B7 ", detail] })] }));
|
|
23
|
+
} })) : (_jsx(Text, { color: textMuted, children: "No receivers discovered." })), _jsx(Text, { color: textDim, children: canEnterCode ? 'Enter selects · c opens code entry · r refreshes receivers' : 'Enter selects · r refreshes receivers' })] })] }));
|
|
24
24
|
}
|