@ciphore/radiocli 0.1.0 → 0.1.2

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 CHANGED
@@ -5,7 +5,16 @@ All notable changes to RadioCLI are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.1.0] - 2026-05-30
8
+ ## [0.1.2] - 2026-05-30
9
+
10
+ ### Fixed
11
+
12
+ - Ignored Kitty keyboard release events so Ghostty users do not skip multiple
13
+ tabs from one left/right arrow key press.
14
+ - Ignored Kitty release events for raw media-key transport sequences so learned
15
+ or native media keys do not double-fire on key release.
16
+
17
+ ## [0.1.1] - 2026-05-30
9
18
 
10
19
  Initial public release.
11
20
 
@@ -19,7 +28,7 @@ Initial public release.
19
28
  resolution, plus an experimental, opt-in Radio Garden provider.
20
29
  - `mpv` playback with `ffplay` fallback, readiness checks, ICY metadata polling,
21
30
  volume, pause, mute, sleep timer, tune timeouts, and skip-broken-stream
22
- behavior.
31
+ behavior, using Unix sockets on macOS/Linux and named pipes on native Windows.
23
32
  - Receiver-style Now Playing screen with 50 selectable visualizers and
24
33
  zero-signal frames whenever playback is idle, paused, stopped, or not
25
34
  backend-ready.
@@ -34,7 +43,10 @@ Initial public release.
34
43
  streaks, active days, and total hours listened.
35
44
  - `radiocli doctor` to report local playback readiness and print OS-specific
36
45
  setup guidance.
37
- - Homebrew formula template for `brew install` distribution.
46
+ - Published Homebrew tap formula for `brew install ciphore/tap/radiocli`.
47
+ - Documented native Linux and Windows install paths, with `winget` guidance for
48
+ Node.js, `mpv`, and optional FFmpeg on Windows.
49
+ - Documented pnpm and Bun as optional npm-package install clients.
38
50
 
39
51
  ### Changed
40
52
 
@@ -44,4 +56,5 @@ Initial public release.
44
56
  backend/volume that already appear in the header and footer. The reclaimed
45
57
  rows go to the visualizer.
46
58
 
47
- [0.1.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.0
59
+ [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
60
+ [0.1.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.1
package/CONTRIBUTING.md CHANGED
@@ -18,7 +18,11 @@ Install `mpv` for the best playback experience:
18
18
  brew install mpv
19
19
  ```
20
20
 
21
- On Linux, use your package manager's `mpv` package.
21
+ On Linux, use your package manager's `mpv` package. On Windows, use:
22
+
23
+ ```powershell
24
+ winget install --id shinchiro.mpv -e
25
+ ```
22
26
 
23
27
  ## Verification
24
28
 
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![RadioCLI Now Playing visualizer demo](apps/docs/public/demo/radiocli-now-playing.gif)
2
+
1
3
  # RadioCLI
2
4
 
3
5
  [![CI](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml/badge.svg)](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml)
@@ -24,6 +26,32 @@ It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://rea
24
26
  - Survive ordinary internet-radio failure modes with provider mirror fallback, stale cache fallback, corrupt-file backups, tune timeouts, and skip-broken-stream behavior.
25
27
  - Resize with the terminal. The app listens for terminal resize events and recomputes list row counts, map density, receiver width, and compact-mode fallback from the current dimensions.
26
28
 
29
+ ## Visual Tour
30
+
31
+ The GIFs below are recorded from the real built TUI with `npm run demo:assets`.
32
+ The top GIF cycles multiple Now Playing receiver styles and display colors while
33
+ a real stream is playing through `mpv`.
34
+
35
+ ### Library: Favorites And Recents
36
+
37
+ ![RadioCLI library showing favorites and recent stations](apps/docs/public/demo/radiocli-library.gif)
38
+
39
+ ### Explore: World Map Discovery
40
+
41
+ ![RadioCLI Explore world map with nearby station results](apps/docs/public/demo/radiocli-explore-map.gif)
42
+
43
+ ### Search: Query Public Station Directories
44
+
45
+ ![RadioCLI search results for tokyo jazz](apps/docs/public/demo/radiocli-search.gif)
46
+
47
+ ### Nearby: Opt-In Local Stations
48
+
49
+ ![RadioCLI Nearby station list](apps/docs/public/demo/radiocli-nearby.gif)
50
+
51
+ ### Stats: Listening History And Display Colors
52
+
53
+ ![RadioCLI stats screen cycling display colors](apps/docs/public/demo/radiocli-stats-colors.gif)
54
+
27
55
  ## Demo
28
56
 
29
57
  The interactive TUI opens directly into the product, not a marketing screen:
@@ -109,29 +137,34 @@ Set `NEXT_PUBLIC_SITE_URL` for the canonical public docs URL. Preview builds als
109
137
 
110
138
  Requirements:
111
139
 
112
- - Node.js 22 or newer
140
+ - Homebrew on macOS: installs RadioCLI, Node.js, and `mpv`
141
+ - npm on macOS, Linux, and Windows: Node.js 22 or newer
113
142
  - `mpv` for best playback; RadioCLI expects one local playback backend at runtime
114
143
  - `ffplay` from FFmpeg as an optional fallback
115
144
 
116
- NPM installs RadioCLI and its JavaScript dependencies. It does not install native
117
- system playback tools. Use `radiocli doctor` after installation to check local
118
- playback readiness and get the right setup command for your OS:
145
+ Recommended macOS install:
119
146
 
120
147
  ```bash
121
- npm install -g @ciphore/radiocli
122
- radiocli doctor
148
+ brew install ciphore/tap/radiocli
123
149
  radiocli
124
150
  ```
125
151
 
126
- macOS with npm:
152
+ The Homebrew formula depends on `node` and `mpv`, so the native playback backend
153
+ comes from the native package manager.
154
+
155
+ Universal npm install:
127
156
 
128
157
  ```bash
129
- brew install mpv
130
158
  npm install -g @ciphore/radiocli
159
+ radiocli doctor
131
160
  radiocli
132
161
  ```
133
162
 
134
- Linux with npm:
163
+ The npm package is `@ciphore/radiocli`, and the installed executable is
164
+ `radiocli`. npm installs RadioCLI and its JavaScript dependencies only; it does
165
+ not install system playback tools.
166
+
167
+ Debian/Ubuntu:
135
168
 
136
169
  ```bash
137
170
  sudo apt install mpv
@@ -139,25 +172,39 @@ npm install -g @ciphore/radiocli
139
172
  radiocli
140
173
  ```
141
174
 
142
- `ffplay` is optional fallback support. Install FFmpeg separately if you want it:
175
+ Fedora, Arch, Alpine, and openSUSE users can install the distro `mpv` package
176
+ first, then install `@ciphore/radiocli` with npm.
177
+
178
+ Native Windows with Windows Terminal or PowerShell:
179
+
180
+ ```powershell
181
+ winget install --id OpenJS.NodeJS.LTS -e
182
+ winget install --id shinchiro.mpv -e
183
+ npm install -g @ciphore/radiocli
184
+ radiocli doctor
185
+ radiocli
186
+ ```
187
+
188
+ Scoop users can use `scoop bucket add extras` and `scoop install mpv` instead
189
+ of the `winget` mpv command. WSL remains supported through the Linux path.
190
+
191
+ Optional `ffplay` fallback support:
192
+
193
+ macOS/Linux:
143
194
 
144
195
  ```bash
145
196
  brew install ffmpeg # macOS
146
197
  sudo apt install ffmpeg # Debian/Ubuntu
147
198
  ```
148
199
 
149
- The repo also includes a Homebrew formula template in `packaging/homebrew` for a
150
- native one-command macOS tap. Once the tap formula is published, the intended
151
- Homebrew install path is:
200
+ Windows:
152
201
 
153
- ```bash
154
- brew install ciphore/tap/radiocli
202
+ ```powershell
203
+ winget install --id Gyan.FFmpeg -e
155
204
  ```
156
205
 
157
- That formula depends on `node` and `mpv`, keeping native dependencies in the
158
- native package manager instead of running system installs from npm.
159
-
160
- CI covers command-mode typecheck, tests, builds, and package checks on Ubuntu. Native Windows terminals are not release-tested yet; use WSL with Linux `mpv` / `ffplay` for the supported path.
206
+ CI covers command-mode typecheck, tests, builds, package checks, and fresh
207
+ install smoke checks on Ubuntu, macOS, and Windows.
161
208
 
162
209
  Local checkout:
163
210
 
@@ -288,9 +335,57 @@ RadioCLI is split around four seams:
288
335
  - playback lifecycle and metadata in `src/player`
289
336
  - local JSON persistence in `src/storage`
290
337
 
338
+ ### Audio Pipeline
339
+
340
+ Station lists keep provider metadata and a resolvable stream URL. When you tune
341
+ a station, `ProviderManager.resolve()` follows the provider-specific path, then
342
+ `PlayerController` starts `mpv` with `--no-video`, `--force-window=no`, a local
343
+ JSON IPC endpoint, and the configured volume. The endpoint is a Unix socket on
344
+ macOS/Linux and a named pipe on native Windows. `ffplay` is available as a
345
+ simpler fallback, but `mpv` is the intended backend because it handles
346
+ redirects, HLS, codecs, metadata, pause, mute, volume, and readiness checks more
347
+ reliably than a JavaScript stream client.
348
+
349
+ RadioCLI waits for the backend to become ready before marking playback as
350
+ `playing`. With `mpv`, it polls playback state every 500ms and ICY metadata every
351
+ 2.5s, then cleans the metadata before showing it in the receiver and live footer.
352
+
353
+ ### FFT Processing
354
+
355
+ RadioCLI does not currently tap decoded PCM audio or run a real FFT in the Node
356
+ process. The receiver visuals are procedural signal displays driven by playback
357
+ truth, the selected style, terminal dimensions, theme, and a small `pulse`
358
+ counter. That keeps the TUI lightweight and avoids duplicating audio decoding
359
+ work already handled by `mpv`. The spectrum-like modes are generated from
360
+ deterministic samples, so they should be read as receiver visualizers rather than
361
+ measurement-grade audio analysis.
362
+
363
+ ### Terminal Rendering And Refresh Rate
364
+
365
+ Ink renders React components into ANSI terminal frames. The app recomputes a
366
+ terminal layout from the current row/column size, then each screen gets stable
367
+ row budgets for station lists, the map, receiver panels, and the adaptive
368
+ footer. Visualizers return text rows or colored text segments; the Now Playing
369
+ screen frames those rows inside the receiver panel.
370
+
371
+ Live receiver animation advances every 80ms, about 12.5 frames per second, only
372
+ on the Now Playing screen while playback is `playing` and backend-ready.
373
+ Ambient/idle-style animation uses a slower 140ms interval, and the loading
374
+ spinner uses 120ms. Inactive playback states render zero-signal frames instead
375
+ of animating.
376
+
377
+ ### CPU Usage
378
+
379
+ CPU cost is intentionally bounded: audio decode stays in the native backend,
380
+ there is no JavaScript FFT worker, metadata polling is infrequent, and the pulse
381
+ timer does not run outside live Now Playing. In practice the terminal renderer
382
+ does string and color-segment generation for the current screen only; `mpv` does
383
+ the stream work, and idle/library/search/map screens do not pay the visualizer
384
+ animation cost.
385
+
291
386
  Radio Browser is the primary provider. Its own docs recommend using a speaking user agent, resolving station clicks through `/json/url`, and retrying with other servers when one fails; RadioCLI follows that shape with mirror fallback and durable cache. Explore and Nearby use a cached geotagged Radio Browser atlas, then compute local distance in the app so map movement is not biased toward the most-clicked stations worldwide. Radio Garden support is experimental because the useful endpoints are publicly discoverable but unofficial, and they can be blocked or changed independently of this project.
292
387
 
293
- Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling.
388
+ Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling, using Unix sockets on macOS/Linux and named pipes on Windows.
294
389
 
295
390
  The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. Current installs store data under RadioCLI paths such as `radiocli.json` and `radiocli-cache.json`. Existing Radio Atlas data is still discovered when a new RadioCLI store does not exist, and legacy `RADIO_ATLAS_HOME` / animation environment variables remain supported as migration fallbacks. New automation should use `RADIOCLI_HOME` and `RADIOCLI_DISABLE_ANIMATION`.
296
391
 
@@ -39,7 +39,7 @@ export function mpvInstallCommand(platform = process.platform, osRelease = readL
39
39
  return 'brew install mpv';
40
40
  }
41
41
  if (platform === 'win32') {
42
- return 'use WSL with sudo apt install mpv, or install mpv from https://mpv.io/installation/';
42
+ return 'winget install --id shinchiro.mpv -e';
43
43
  }
44
44
  if (platform !== 'linux') {
45
45
  return 'install mpv with your system package manager';
@@ -67,7 +67,7 @@ function ffplayInstallCommand(platform = process.platform, osRelease = readLinux
67
67
  return 'brew install ffmpeg';
68
68
  }
69
69
  if (platform === 'win32') {
70
- return 'use WSL with sudo apt install ffmpeg, or install FFmpeg from https://ffmpeg.org/download.html';
70
+ return 'winget install --id Gyan.FFmpeg -e';
71
71
  }
72
72
  if (platform !== 'linux') {
73
73
  return 'install FFmpeg with your system package manager';
@@ -169,7 +169,7 @@ export class PlayerController {
169
169
  return null;
170
170
  }
171
171
  playWithMpv(url, initialTitle) {
172
- this.ipcPath = join(tmpdir(), `radiocli-${process.pid}-${Date.now()}.sock`);
172
+ this.ipcPath = createMpvIpcPath();
173
173
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
174
174
  this.process = spawn('mpv', [
175
175
  '--no-video',
@@ -296,7 +296,7 @@ export class PlayerController {
296
296
  await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
297
297
  return;
298
298
  }
299
- if (this.ipcPath && existsSync(this.ipcPath)) {
299
+ if (this.ipcPath) {
300
300
  try {
301
301
  await this.queryMpv({ command: ['get_property', 'path'] });
302
302
  return;
@@ -374,7 +374,7 @@ export class PlayerController {
374
374
  }
375
375
  }
376
376
  cleanupIpc() {
377
- if (this.ipcPath && existsSync(this.ipcPath)) {
377
+ if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
378
378
  try {
379
379
  unlinkSync(this.ipcPath);
380
380
  }
@@ -392,6 +392,15 @@ export class PlayerController {
392
392
  }
393
393
  }
394
394
  }
395
+ export function createMpvIpcPath(platform = process.platform, pid = process.pid, timestamp = Date.now()) {
396
+ if (platform === 'win32') {
397
+ return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
398
+ }
399
+ return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
400
+ }
401
+ function isWindowsNamedPipePath(path) {
402
+ return path.startsWith('\\\\.\\pipe\\');
403
+ }
395
404
  function clampVolume(volume) {
396
405
  return Math.min(100, Math.max(0, Math.round(volume)));
397
406
  }
@@ -188,8 +188,13 @@ export function mediaTransportActionForInput(input, mediaKeys = emptyMediaKeyBin
188
188
  if (keyCode === '20') {
189
189
  return 'next';
190
190
  }
191
- const kittyMatches = [...input.matchAll(/\u001B\[(57428|57429|57430|57431|57433|57434|57435|57436)(?:;[0-9:;]+)?u/g)];
192
- const kittyCode = kittyMatches.at(-1)?.[1];
191
+ const kittyMatches = [...input.matchAll(/\u001B\[(57428|57429|57430|57431|57433|57434|57435|57436)(?:;\d+(?::(\d+))?(?:;[\d:]+)?)?u/g)];
192
+ const kittyMatch = kittyMatches.at(-1);
193
+ const kittyCode = kittyMatch?.[1];
194
+ const kittyEventType = kittyMatch?.[2];
195
+ if (kittyEventType === '3') {
196
+ return null;
197
+ }
193
198
  if (kittyCode === '57431' || kittyCode === '57434' || kittyCode === '57436') {
194
199
  return 'previous';
195
200
  }
@@ -209,6 +214,9 @@ export function mediaTransportActionForInput(input, mediaKeys = emptyMediaKeyBin
209
214
  }
210
215
  return null;
211
216
  }
217
+ export function shouldHandleKeyboardEvent(eventType) {
218
+ return eventType !== 'release';
219
+ }
212
220
  export function normalizeMediaKeyBindings(mediaKeys) {
213
221
  return {
214
222
  previous: uniqueStrings(mediaKeys?.previous ?? []),
@@ -35,7 +35,8 @@ function buildContributionGraph(days, width) {
35
35
  cells: weeks.map(week => {
36
36
  const day = week[dayIndex];
37
37
  const level = contributionLevel(day?.seconds ?? 0, maxSeconds);
38
- return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text: '█'.repeat(cellWidth) };
38
+ const text = cellWidth > 1 ? `${'█'.repeat(cellWidth - 1)} ` : '█';
39
+ return { key: day?.date ?? `${week[0]?.date ?? 'empty'}-${dayIndex}`, level, text };
39
40
  })
40
41
  }));
41
42
  return { months: monthLine(weeks, cellWidth), rows };
package/dist/ui/theme.js CHANGED
@@ -54,46 +54,47 @@ export function themeAccent(theme) {
54
54
  return '#74f28a';
55
55
  }
56
56
  export function themeContributionColors(theme) {
57
+ const emptyContribution = '#1c1c1c';
57
58
  if (theme === 'amber') {
58
- return ['#161b22', '#5f3700', '#9a6200', '#d68a00', '#ffb000'];
59
+ return [emptyContribution, '#5f3700', '#9a6200', '#d68a00', '#ffb000'];
59
60
  }
60
61
  if (theme === 'blue') {
61
- return ['#161b22', '#12385f', '#1f6feb', '#3388dd', '#53a8ff'];
62
+ return [emptyContribution, '#12385f', '#1f6feb', '#3388dd', '#53a8ff'];
62
63
  }
63
64
  if (theme === 'ruby') {
64
- return ['#161b22', '#4c1230', '#8f274f', '#c93f68', '#ff5f87'];
65
+ return [emptyContribution, '#4c1230', '#8f274f', '#c93f68', '#ff5f87'];
65
66
  }
66
67
  if (theme === 'ice') {
67
- return ['#161b22', '#24474d', '#4a95a0', '#86dce8', '#b9f6ff'];
68
+ return [emptyContribution, '#24474d', '#4a95a0', '#86dce8', '#b9f6ff'];
68
69
  }
69
70
  if (theme === 'teal') {
70
- return ['#161b22', '#123f3c', '#1f766c', '#2dd4bf', '#5eead4'];
71
+ return [emptyContribution, '#123f3c', '#1f766c', '#2dd4bf', '#5eead4'];
71
72
  }
72
73
  if (theme === 'violet') {
73
- return ['#161b22', '#302047', '#5b3f8f', '#7c5cff', '#a78bfa'];
74
+ return [emptyContribution, '#302047', '#5b3f8f', '#7c5cff', '#a78bfa'];
74
75
  }
75
76
  if (theme === 'copper') {
76
- return ['#161b22', '#44281f', '#7f4f37', '#b86f52', '#d08770'];
77
+ return [emptyContribution, '#44281f', '#7f4f37', '#b86f52', '#d08770'];
77
78
  }
78
79
  if (theme === 'cyan') {
79
- return ['#161b22', '#0e3b42', '#168a9e', '#22b8cf', '#22d3ee'];
80
+ return [emptyContribution, '#0e3b42', '#168a9e', '#22b8cf', '#22d3ee'];
80
81
  }
81
82
  if (theme === 'lime') {
82
- return ['#161b22', '#2f3d12', '#5f7d1f', '#84b32b', '#a3e635'];
83
+ return [emptyContribution, '#2f3d12', '#5f7d1f', '#84b32b', '#a3e635'];
83
84
  }
84
85
  if (theme === 'coral') {
85
- return ['#161b22', '#4c2018', '#9a4032', '#d65c49', '#ff7e6b'];
86
+ return [emptyContribution, '#4c2018', '#9a4032', '#d65c49', '#ff7e6b'];
86
87
  }
87
88
  if (theme === 'rose') {
88
- return ['#161b22', '#4c2238', '#8f3f6a', '#d65c9a', '#ff8fc7'];
89
+ return [emptyContribution, '#4c2238', '#8f3f6a', '#d65c9a', '#ff8fc7'];
89
90
  }
90
91
  if (theme === 'slate') {
91
- return ['#161b22', '#2a3340', '#4f6178', '#7c91ab', '#9fb4cf'];
92
+ return [emptyContribution, '#2a3340', '#4f6178', '#7c91ab', '#9fb4cf'];
92
93
  }
93
94
  if (theme === 'mono') {
94
- return ['#161b22', '#3a3a3a', '#767676', '#b0b0b0', '#d0d0d0'];
95
+ return [emptyContribution, '#3a3a3a', '#767676', '#b0b0b0', '#d0d0d0'];
95
96
  }
96
- return ['#161b22', '#0e4429', '#26a641', '#39d353', '#74f28a'];
97
+ return [emptyContribution, '#0e4429', '#26a641', '#39d353', '#74f28a'];
97
98
  }
98
99
  export function nextTheme(theme) {
99
100
  const index = themeNames.indexOf(theme);
@@ -1,7 +1,7 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useInput } from 'ink';
3
3
  import { homeItems, settingsItems } from './screen-items.js';
4
- import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput } from './app-state.js';
4
+ import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
5
5
  import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
6
6
  export function useAppInput({ adjustVolume, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, setCapturingTransportAction, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
7
7
  useEffect(() => {
@@ -71,6 +71,9 @@ export function useAppInput({ adjustVolume, beginLearningTransportKey, capturing
71
71
  shutdown();
72
72
  return;
73
73
  }
74
+ if (!shouldHandleKeyboardEvent(key.eventType)) {
75
+ return;
76
+ }
74
77
  if (capturingTransportAction) {
75
78
  return;
76
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "smoke:data": "tsx src/smoke/data-smoke.ts",
62
62
  "smoke:playback": "tsx src/smoke/playback-smoke.ts",
63
63
  "demo:script": "node scripts/demo-script.mjs",
64
+ "demo:assets": "node scripts/capture-demo-assets.mjs",
64
65
  "fresh:check": "node scripts/fresh-check.mjs"
65
66
  },
66
67
  "dependencies": {