@ciphore/radiocli 0.2.5 → 0.2.6

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
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to RadioCLI are documented in this file.
4
4
 
5
+ ## [0.2.6] - 2026-10-28
6
+
7
+ ### Added
8
+
9
+ - Added the Boat Lake receiver, bringing the collection to 64 animated styles.
10
+ - Expanded the display palette from 14 to 48 colors, with matching listening
11
+ activity shades and color choices in the website receiver gallery.
12
+
13
+ ### Changed
14
+
15
+ - Refined Sumi Ocean with a clearer moon, moving water, and wider reflections
16
+ that remain readable in compact and micro layouts.
17
+ - Updated the website receiver previews and project demos to match the terminal
18
+ renderers, including Boat Lake, Sumi Ocean, and the expanded color palette.
19
+
20
+ ### Fixed
21
+
22
+ - Prevented long-running animated receiver sessions from exhausting Node.js
23
+ memory, including Manhattan and the other scenery styles, across full,
24
+ compact, and micro layouts.
25
+ - Kept normal TUI launches on the production React runtime while preserving
26
+ explicitly enabled development diagnostics.
27
+ - Preserved new colors and receiver selections while older RadioCLI and MCP
28
+ processes continue reading and writing the same library.
29
+ - Avoided transient library-lock failures on Windows while another writer
30
+ releases its lock.
31
+
32
+ ### Security
33
+
34
+ - Updated Zod and Undici to patched versions within their existing dependency
35
+ ranges.
36
+
5
37
  ## [0.2.5] - 2026-10-18
6
38
 
7
39
  ### Added
@@ -546,3 +578,4 @@ Initial public release.
546
578
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
547
579
  [0.1.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.1
548
580
  [0.2.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.5
581
+ [0.2.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.6
package/CONTRIBUTING.md CHANGED
@@ -95,6 +95,24 @@ major screens, and receiver styles. Preserve Unicode captures. When rendering
95
95
  changes intentionally, describe the visible effect in the pull request and do
96
96
  not update snapshots merely to hide failures.
97
97
 
98
+ Receiver changes also run an isolated real-Ink memory regression in the normal
99
+ test suite. For a longer accelerated soak (frames run without the 80 ms delay):
100
+
101
+ ```bash
102
+ node --expose-gc --import tsx scripts/receiver-memory-smoke.mjs --frames=10000
103
+ ```
104
+
105
+ The harness updates one persistent Ink root, discards terminal output, waits
106
+ for each frame to flush, and compares
107
+ post-GC JavaScript heap after warmup; RSS includes allocator high-water memory
108
+ and is recorded separately. It cycles scenery, sizes, themes, ASCII, and
109
+ background settings. Like the CLI, it defaults to the production React runtime
110
+ before loading the renderer. An explicit `NODE_ENV=development` keeps React's
111
+ developer diagnostics, including profiling entries retained by Node; use the
112
+ production runtime for memory acceptance. Node 26 has an additional receiver-only CI check on Linux,
113
+ macOS, and Windows for issue #55; this does not replace the full Node 22/24
114
+ platform matrix or establish full Node 26 platform verification.
115
+
98
116
  ## Issue Triage
99
117
 
100
118
  Playback issues should include station name, country, backend, and
package/README.md CHANGED
@@ -4,7 +4,7 @@ RadioCLI is an open-source internet radio player with a full-screen terminal
4
4
  interface and a scriptable CLI. Discover and stream stations from around the
5
5
  world without leaving your terminal. No account. No cloud library.
6
6
 
7
- [![CI](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml)
7
+ [![CI](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml?query=branch%3Amaster+event%3Apush)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
9
  [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](package.json)
10
10
 
@@ -16,9 +16,9 @@ world without leaving your terminal. No account. No cloud library.
16
16
  codec, and bitrate, or browse country lists. The interactive world map uses a
17
17
  cached station atlas to find broadcasts around the map cursor and sort them by
18
18
  distance. Cached directory results remain available during provider outages.
19
- - **63 receiver styles:** Switch between braille animation, ASCII geometry,
19
+ - **64 receiver styles:** Switch between braille animation, ASCII geometry,
20
20
  waves, fire, cityscapes, and the Gallop display without interrupting
21
- playback. Color themes are selected independently. Receivers animate during
21
+ playback. Choose from 48 color themes independently. Receivers animate during
22
22
  active playback and return to a zero-signal display when audio is inactive.
23
23
  - **Listening stats:** A 52-week activity graph, listening time, session counts,
24
24
  active days, streaks, and your most-listened station. Stats are recorded locally
package/dist/cli.js CHANGED
@@ -36,6 +36,10 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
36
36
  }
37
37
  }
38
38
  async function renderTui(encodedCommand) {
39
+ // React's development profiler retains per-frame timing entries in Node.
40
+ // Choose the production runtime before loading Ink or JSX; explicit developer
41
+ // environments still take precedence.
42
+ process.env.NODE_ENV ??= 'production';
39
43
  const { renderTui: render } = await import('./cli/render-tui.js');
40
44
  await render(printHelp, encodedCommand);
41
45
  }
@@ -5,9 +5,21 @@ import { performance } from 'node:perf_hooks';
5
5
  /** An owner is never evicted for being slow. Only a positively dead PID or an
6
6
  * explicitly released generation can be reclaimed. Unknown/legacy locks fail
7
7
  * closed; their age cannot distinguish a crashed writer from a suspended one. */
8
- export function acquireFileLock(directory, timeoutMs = 1000) {
8
+ export function acquireFileLock(directory, timeoutMs = 1000, platform = process.platform) {
9
9
  const marker = `owner-${process.pid}-${randomUUID()}`;
10
10
  const deadline = performance.now() + timeoutMs;
11
+ const pause = new Int32Array(new SharedArrayBuffer(4));
12
+ const retry = (error) => {
13
+ if (performance.now() >= deadline) {
14
+ if (error)
15
+ throw error;
16
+ throw Object.assign(new Error('The library lock has a live or unknown owner.'), { code: 'EBUSY' });
17
+ }
18
+ Atomics.wait(pause, 0, 0, 10);
19
+ };
20
+ // Windows can briefly deny access while a previous lock directory is pending
21
+ // deletion. Retry only before ownership, without treating denial as absence.
22
+ const transient = (error) => platform === 'win32' && ['EACCES', 'EPERM', 'EBUSY'].includes(codeOf(error) ?? '');
11
23
  while (true) {
12
24
  let acquired = false;
13
25
  try {
@@ -15,8 +27,12 @@ export function acquireFileLock(directory, timeoutMs = 1000) {
15
27
  acquired = true;
16
28
  }
17
29
  catch (error) {
18
- if (codeOf(error) !== 'EEXIST')
19
- throw error;
30
+ if (codeOf(error) !== 'EEXIST') {
31
+ if (!transient(error))
32
+ throw error;
33
+ retry(error);
34
+ continue;
35
+ }
20
36
  }
21
37
  if (acquired) {
22
38
  try {
@@ -54,13 +70,14 @@ export function acquireFileLock(directory, timeoutMs = 1000) {
54
70
  }
55
71
  }
56
72
  catch (error) {
57
- if (codeOf(error) !== 'ENOENT')
58
- throw error;
59
- }
60
- if (performance.now() >= deadline) {
61
- throw Object.assign(new Error('The library lock has a live or unknown owner.'), { code: 'EBUSY' });
73
+ if (codeOf(error) !== 'ENOENT') {
74
+ if (!transient(error))
75
+ throw error;
76
+ retry(error);
77
+ continue;
78
+ }
62
79
  }
63
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
80
+ retry();
64
81
  }
65
82
  }
66
83
  function isDead(pid) {
@@ -18,16 +18,36 @@ const defaultMediaKeys = {
18
18
  next: []
19
19
  };
20
20
  const MAX_ALARMS = 500;
21
- // Keep the shared library readable by pre-scene releases whose background MCP
22
- // workers quarantine an entire library when they see an unfamiliar enum value.
23
- // Crossfade is the last style understood by those releases. New styles live in
24
- // a separate preference file that old writers never read or overwrite.
25
- const sharedLibraryReceiverStyles = new Set(receiverStyleNames.slice(0, receiverStyleNames.indexOf('crossfade') + 1));
21
+ // Freeze the 0.2.5 enum independently of UI ordering. New styles must stay out
22
+ // of both its shared library and its strictly validated .receiver.json sidecar.
23
+ // The new namespace is ignored by old playback/MCP readers and writers.
24
+ const sharedLibraryReceiverStyles = new Set([
25
+ 'ultracode', 'pulse-grid', 'hex-pulse', 'moire', 'galaxy', 'cyclone', 'nebula', 'lava-lamp',
26
+ 'gallop-fine', 'motion-contour', 'ribbon', 'braille-wave', 'silk', 'lissajous', 'harmonograph',
27
+ 'spirograph', 'goniometer', 'radial-eq', 'spectrogram', 'caustics', 'ripple-tank', 'leds',
28
+ 'matrix', 'cascade', 'ordered-dither', 'xor-texture', 'pixel-crush', 'copper-bars',
29
+ 'rotozoomer', 'mesh', 'hologram', 'equalizer', 'tunnel', 'kaleidoscope', 'phosphene',
30
+ 'phyllotaxis', 'chladni', 'newton', 'cube', 'spinning-donut', 'tesseract', 'torus-knot',
31
+ 'twister', 'lorenz', 'fern', 'fractal-tree', 'julia', 'neural-net', 'constellation',
32
+ 'starfield', 'fireworks', 'fire', 'aurora', 'stormfront', 'planetarium', 'sumi-ocean',
33
+ 'liftoff', 'flyover', 'skyline', 'golden-gate', 'manhattan', 'alpine', 'crossfade'
34
+ ]);
26
35
  const receiverPreferenceSchema = z.object({
27
36
  version: z.literal(1),
28
37
  receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)),
29
38
  libraryReceiverStyle: z.literal('ultracode')
30
39
  });
40
+ // Older playback/MCP processes validate this original enum on every refresh.
41
+ // New colors must stay out of their shared JSON, even after unrelated saves.
42
+ const sharedLibraryThemes = new Set([
43
+ 'green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper',
44
+ 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'
45
+ ]);
46
+ const themePreferenceSchema = z.object({
47
+ version: z.literal(1),
48
+ theme: z.enum(themeNames),
49
+ libraryTheme: z.literal('green')
50
+ });
31
51
  const settingsSchema = z.object({
32
52
  theme: z.enum(themeNames).default('green'),
33
53
  receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
@@ -239,7 +259,8 @@ export class JsonLibraryStore {
239
259
  this.state = this.read();
240
260
  // Normalize libraries saved by an earlier preview before any legacy
241
261
  // background reader encounters its new enum value again.
242
- if (!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferencePath()))
262
+ if ((!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferenceV2Path())) ||
263
+ (!sharedLibraryThemes.has(this.state.settings.theme) && !existsSync(this.themePreferencePath())))
243
264
  this.commit(this.state);
244
265
  }
245
266
  snapshot() {
@@ -264,7 +285,7 @@ export class JsonLibraryStore {
264
285
  return structuredClone(this.state.settings);
265
286
  }
266
287
  diskRevision() {
267
- return [this.filePath, this.receiverPreferencePath()].map(file => {
288
+ return [this.filePath, this.receiverPreferencePath(), this.receiverPreferenceV2Path(), this.themePreferencePath()].map(file => {
268
289
  try {
269
290
  const info = statSync(file, { bigint: true });
270
291
  return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
@@ -623,10 +644,10 @@ export class JsonLibraryStore {
623
644
  }
624
645
  catch (error) {
625
646
  if (error instanceof z.ZodError && error.issues.some(issue => issue.code === 'invalid_value' && issue.path.length === 2 &&
626
- issue.path[0] === 'settings' && issue.path[1] === 'receiverStyle')) {
627
- // Another running build may have saved a newer receiver. Its library
647
+ issue.path[0] === 'settings' && (issue.path[1] === 'receiverStyle' || issue.path[1] === 'theme'))) {
648
+ // Another running build may have saved a newer appearance. Its library
628
649
  // is intact: never quarantine it or replace it with empty defaults.
629
- throw new Error('The RadioCLI library uses a receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated styles.');
650
+ throw new Error('The RadioCLI library uses a display color or receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated colors and styles.');
630
651
  }
631
652
  try {
632
653
  assertStorageWritable(this.filePath);
@@ -640,23 +661,47 @@ export class JsonLibraryStore {
640
661
  }
641
662
  // A legacy writer can still change ordinary preferences and favorites. Its
642
663
  // fallback style must not erase the selection saved by an updated process.
664
+ // The newer namespace wins over a leftover preview preference. Continue
665
+ // reading the old namespace when no newer selection has been saved.
666
+ for (const path of [this.receiverPreferenceV2Path(), this.receiverPreferencePath()]) {
667
+ let preference;
668
+ try {
669
+ preference = readFileSync(path, 'utf8');
670
+ }
671
+ catch (error) {
672
+ if (error.code === 'ENOENT')
673
+ continue;
674
+ throw storageError('read the receiver preference', error);
675
+ }
676
+ const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
677
+ if (!parsed.success)
678
+ throw new Error('The RadioCLI receiver preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
679
+ if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
680
+ state.settings.receiverStyle = parsed.data.receiverStyle;
681
+ break;
682
+ }
683
+ return this.readThemePreference(state);
684
+ }
685
+ receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
686
+ receiverPreferenceV2Path() { return `${this.filePath}.receiver-v2.json`; }
687
+ themePreferencePath() { return `${this.filePath}.theme.json`; }
688
+ readThemePreference(state) {
643
689
  let preference;
644
690
  try {
645
- preference = readFileSync(this.receiverPreferencePath(), 'utf8');
691
+ preference = readFileSync(this.themePreferencePath(), 'utf8');
646
692
  }
647
693
  catch (error) {
648
694
  if (error.code === 'ENOENT')
649
695
  return state;
650
- throw storageError('read the receiver preference', error);
696
+ throw storageError('read the display color preference', error);
651
697
  }
652
- const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
698
+ const parsed = themePreferenceSchema.safeParse(JSON.parse(preference));
653
699
  if (!parsed.success)
654
- throw new Error('The RadioCLI receiver preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
655
- if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
656
- state.settings.receiverStyle = parsed.data.receiverStyle;
700
+ throw new Error('The RadioCLI display color preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
701
+ if (state.settings.theme === parsed.data.libraryTheme)
702
+ state.settings.theme = parsed.data.theme;
657
703
  return state;
658
704
  }
659
- receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
660
705
  readForCurrentSession() {
661
706
  const next = this.read();
662
707
  // Disk intentionally stores auto instead of reopening a receiver on launch.
@@ -685,36 +730,56 @@ export class JsonLibraryStore {
685
730
  mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
686
731
  const diskState = libraryStateForDisk(state);
687
732
  const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle);
688
- const preferencePath = this.receiverPreferencePath();
689
- let previousPreference;
690
- try {
691
- previousPreference = readFileSync(preferencePath, 'utf8');
692
- }
693
- catch (error) {
694
- if (error.code !== 'ENOENT')
695
- throw error;
696
- }
697
- const nextPreference = separateReceiver
698
- ? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
699
- : undefined;
700
- const preferenceChanged = previousPreference !== nextPreference;
701
- if (preferenceChanged) {
702
- if (nextPreference === undefined)
703
- rmSync(preferencePath);
704
- else
705
- writeTextAtomically(preferencePath, nextPreference);
706
- }
733
+ const separateTheme = !sharedLibraryThemes.has(state.settings.theme);
734
+ const preferences = [
735
+ {
736
+ path: this.receiverPreferenceV2Path(),
737
+ next: separateReceiver
738
+ ? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
739
+ : undefined
740
+ },
741
+ { path: this.receiverPreferencePath(), next: undefined },
742
+ {
743
+ path: this.themePreferencePath(),
744
+ next: separateTheme
745
+ ? `${JSON.stringify({ version: 1, theme: state.settings.theme, libraryTheme: 'green' }, null, 2)}\n`
746
+ : undefined
747
+ }
748
+ ].map(preference => {
749
+ let previous;
750
+ try {
751
+ previous = readFileSync(preference.path, 'utf8');
752
+ }
753
+ catch (error) {
754
+ if (error.code !== 'ENOENT')
755
+ throw error;
756
+ }
757
+ return { ...preference, previous };
758
+ }).filter(preference => preference.previous !== preference.next);
759
+ const applied = [];
707
760
  try {
708
- writeJsonAtomically(this.filePath, separateReceiver
709
- ? { ...diskState, settings: { ...diskState.settings, receiverStyle: 'ultracode' } }
710
- : diskState);
761
+ for (const preference of preferences) {
762
+ if (preference.next === undefined)
763
+ rmSync(preference.path);
764
+ else
765
+ writeTextAtomically(preference.path, preference.next);
766
+ applied.push(preference);
767
+ }
768
+ writeJsonAtomically(this.filePath, {
769
+ ...diskState,
770
+ settings: {
771
+ ...diskState.settings,
772
+ ...(separateReceiver ? { receiverStyle: 'ultracode' } : {}),
773
+ ...(separateTheme ? { theme: 'green' } : {})
774
+ }
775
+ });
711
776
  }
712
777
  catch (error) {
713
- if (preferenceChanged) {
714
- if (previousPreference === undefined)
715
- rmSync(preferencePath, { force: true });
778
+ for (const preference of applied.reverse()) {
779
+ if (preference.previous === undefined)
780
+ rmSync(preference.path, { force: true });
716
781
  else
717
- writeTextAtomically(preferencePath, previousPreference);
782
+ writeTextAtomically(preference.path, preference.previous);
718
783
  }
719
784
  throw error;
720
785
  }
package/dist/types.js CHANGED
@@ -1,7 +1,28 @@
1
1
  import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/receiver-style-registry.js';
2
2
  export { defaultReceiverStyle, receiverStyleNames };
3
3
  const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
4
- export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
4
+ export const themeNames = [
5
+ // Reds
6
+ 'vermilion', 'scarlet',
7
+ // Oranges and earth tones
8
+ 'cocoa', 'copper', 'tangerine', 'blaze-orange', 'coral', 'apricot',
9
+ // Golds and creams
10
+ 'bronze', 'amber', 'sunflower', 'butter', 'parchment', 'ivory',
11
+ // Yellow greens
12
+ 'olive', 'lime', 'chartreuse', 'acid-yellow', 'pistachio',
13
+ // Greens
14
+ 'pine', 'laser-green', 'green',
15
+ // Teals and cyans
16
+ 'midnight-teal', 'jade', 'teal', 'cyan', 'ice',
17
+ // Blues
18
+ 'cobalt', 'blue', 'slate', 'periwinkle', 'ultramarine', 'indigo',
19
+ // Purples and mauves
20
+ 'neon-violet', 'violet', 'aubergine', 'plum', 'heather', 'dusty-mauve',
21
+ // Magentas and pinks
22
+ 'hot-magenta', 'wineberry', 'raspberry', 'ruby', 'dracula-pink', 'rose', 'blush',
23
+ // Neutrals
24
+ 'soft-silver', 'mono'
25
+ ];
5
26
  export const defaultAgentControlSettings = {
6
27
  enabled: false,
7
28
  openUiOnPlay: true,
@@ -19,6 +19,7 @@ import { formatExploreCursor } from './app-state.js';
19
19
  import { buildCosmoWorldMap } from './cosmo-world-map.js';
20
20
  import { adaptiveExploreFrameMetrics, computeAdaptiveExploreLayout } from './adaptive-explore-layout.js';
21
21
  import { AdaptiveMarquee } from './components/AdaptiveMarquee.js';
22
+ import { ReceiverVisualRow } from './components/ReceiverVisualRow.js';
22
23
  export function AdaptiveContent(props) {
23
24
  return _jsx(AdaptiveContentBody, { ...props });
24
25
  }
@@ -225,7 +226,7 @@ function StaticRows({ rows, width, theme }) {
225
226
  }
226
227
  function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height, ascii, theme, receiverStyle, reduceMotion }) {
227
228
  const pulse = useReceiverPulse();
228
- const { screenReader } = useDisplay();
229
+ const { screenReader, app: appBackground } = useDisplay();
229
230
  const accent = themeAccent(theme);
230
231
  const stationName = station?.name ?? 'No station tuned';
231
232
  const showMetadata = Boolean(metadata?.title) && (screenReader || mode === 'compact' && height >= 9);
@@ -241,19 +242,7 @@ function AdaptiveNowPlaying({ mode, station, playback, metadata, width, height,
241
242
  const status = `${stationName} ${playback.state}${playback.muted ? ' · muted' : ` · vol ${playback.volume}`}`;
242
243
  return (_jsxs(Box, { flexDirection: "column", height: height, width: width, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, "aria-label": mode === 'micro' ? `Now playing. Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}` : undefined, children: mode === 'micro'
243
244
  ? _jsx(AdaptiveMarquee, { text: header, width: width, active: true, reduceMotion: reduceMotion })
244
- : truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, "aria-label": `Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, !screenReader ? _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", "aria-hidden": true, children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
245
- ? renderAdaptiveSegments(row.segments, ascii)
246
- : ascii
247
- ? toAsciiSafe(row.text)
248
- : row.text }, index))) }) : null, showMetadata ? (_jsx(Text, { color: accent, "aria-label": `Track: ${metadata?.title ?? ''}`, children: _jsx(AdaptiveMarquee, { text: metadata?.title ?? '', width: width, active: true, reduceMotion: reduceMotion }) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
249
- }
250
- function renderAdaptiveSegments(segments, ascii) {
251
- let offset = 0;
252
- return segments.map(segment => {
253
- const key = offset;
254
- offset += segment.text.length;
255
- return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: ascii ? toAsciiSafe(segment.text) : segment.text }, key));
256
- });
245
+ : truncate(header, width) }), mode === 'compact' ? _jsx(Text, { color: textMuted, "aria-label": `Station: ${stationName}. Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: ascii ? toAsciiSafe(truncate(status, width)) : truncate(status, width) }) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null, !screenReader ? _jsx(Box, { flexDirection: "column", height: availableVisualRows, overflow: "hidden", "aria-hidden": true, children: visualRows.map((row, index) => (_jsx(ReceiverVisualRow, { row: row, width: width, ascii: ascii, backgroundColor: appBackground }, index))) }) : null, showMetadata ? (_jsx(Text, { color: accent, "aria-label": `Track: ${metadata?.title ?? ''}`, children: _jsx(AdaptiveMarquee, { text: metadata?.title ?? '', width: width, active: true, reduceMotion: reduceMotion }) })) : null, gapRows ? _jsx(Box, { height: gapRows, flexShrink: 0 }) : null] }));
257
246
  }
258
247
  function AdaptiveSearch({ mode, query, editing, loading, rows, empty, selected, height, width, theme, ascii, reduceMotion }) {
259
248
  const accent = themeAccent(theme);
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Transform } from 'ink';
3
+ import { Chalk } from 'chalk';
4
+ import { toAsciiSafe } from '../ascii.js';
5
+ import { useDisplay } from '../display-context.js';
6
+ import { displayWidth, sliceDisplay } from '../format.js';
7
+ const colors = [0, 1, 2, 3].map(level => new Chalk({ level: level }));
8
+ /** Keep animated text out of Ink 7's process-lifetime measurement/wrap caches.
9
+ * Transform must be a direct child of a Box, never nested inside Text: nested
10
+ * transforms are flattened before measurement. Only the fixed-width blank is
11
+ * measured; the current frame is styled at output time and then discarded.
12
+ */
13
+ export function ReceiverVisualRow({ row, width, ascii, backgroundColor }) {
14
+ const { colorLevel } = useDisplay();
15
+ const chalk = colors[colorLevel];
16
+ const segments = row.segments ?? [{ text: row.text, color: row.color }];
17
+ const placeholder = ' '.repeat(Math.max(0, width));
18
+ return _jsx(Transform, { transform: visiblePlaceholder => {
19
+ // Ink clips before invoking Transform. Respect the remaining width so a
20
+ // narrow terminal cannot receive a full row after clipping the placeholder.
21
+ let remaining = displayWidth(visiblePlaceholder);
22
+ let output = '';
23
+ for (const segment of segments) {
24
+ const source = ascii ? toAsciiSafe(segment.text) : segment.text;
25
+ const text = sliceDisplay(source, 0, remaining);
26
+ remaining -= displayWidth(text);
27
+ let styled = chalk.hex(segment.color)(text);
28
+ const background = segment.backgroundColor ?? backgroundColor;
29
+ if (background)
30
+ styled = chalk.bgHex(background)(styled);
31
+ if (segment.bold)
32
+ styled = chalk.bold(styled);
33
+ output += styled;
34
+ if (remaining <= 0 || text !== source)
35
+ break;
36
+ }
37
+ return output;
38
+ }, children: placeholder });
39
+ }
@@ -7,6 +7,7 @@ import { toAsciiSafe } from '../ascii.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
9
9
  import { useReceiverPulse } from '../receiver-animation.js';
10
+ import { ReceiverVisualRow } from '../components/ReceiverVisualRow.js';
10
11
  // Terminal rows are roughly twice as tall as columns are wide. One vertical
11
12
  // row and three horizontal columns produce a closer optical inset around the
12
13
  // highest and lowest visible text than a mechanically equal cell count.
@@ -40,18 +41,8 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
40
41
  : station
41
42
  ? 'Waiting for ICY track metadata'
42
43
  : infoFallback;
43
- const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
44
44
  const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
45
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
46
- ? renderSegments(row.segments)
47
- : row.text }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
48
- }
49
- function asciifyVisualRow(row) {
50
- return {
51
- ...row,
52
- text: toAsciiSafe(row.text),
53
- segments: row.segments?.map(segment => ({ ...segment, text: toAsciiSafe(segment.text) }))
54
- };
45
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: visualRows.map((row, index) => (_jsx(ReceiverVisualRow, { row: row, width: innerWidth, ascii: ascii, backgroundColor: panelBackground }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
55
46
  }
56
47
  export function recentTracksForStation(history, station, limit) {
57
48
  if (!station) {
@@ -74,13 +65,3 @@ export function receiverStationIdentity(station, width) {
74
65
  location: truncate(stationLocation(station).toUpperCase(), locationWidth)
75
66
  };
76
67
  }
77
- function renderSegments(segments) {
78
- let offset = 0;
79
- return segments.map(segment => {
80
- // Keep identity tied to geometry, not animated color. Color-bearing keys
81
- // forced React to unmount and recreate hundreds of Text nodes per frame.
82
- const key = offset;
83
- offset += segment.text.length;
84
- return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: segment.text }, key));
85
- });
86
- }
package/dist/ui/theme.js CHANGED
@@ -13,7 +13,46 @@ export const mapLand = '#33414f';
13
13
  export const mapWater = '#1a2430';
14
14
  export const mapMarker = '#7dd3fc';
15
15
  export const exploreMapLand = '#a0a0c0';
16
+ const additionalThemeAccents = {
17
+ 'acid-yellow': '#f4ff45',
18
+ 'blaze-orange': '#ff8a1f',
19
+ 'hot-magenta': '#ff2bd6',
20
+ ultramarine: '#6670ff',
21
+ vermilion: '#dc322f',
22
+ 'dracula-pink': '#ff79c6',
23
+ 'dusty-mauve': '#c49ab3',
24
+ parchment: '#ddd0aa',
25
+ 'laser-green': '#39ff14',
26
+ heather: '#b48ead',
27
+ 'soft-silver': '#bfc8cd',
28
+ tangerine: '#ff5900',
29
+ scarlet: '#ff1010',
30
+ raspberry: '#d02068',
31
+ indigo: '#5540d8',
32
+ periwinkle: '#9aa8ff',
33
+ jade: '#299e8c',
34
+ cobalt: '#0050ff',
35
+ plum: '#b02e94',
36
+ cocoa: '#946645',
37
+ ivory: '#fff4dc',
38
+ sunflower: '#ffd600',
39
+ butter: '#fff0a0',
40
+ olive: '#858e35',
41
+ apricot: '#ffb875',
42
+ blush: '#f0c3cb',
43
+ aubergine: '#793b71',
44
+ pistachio: '#c5d879',
45
+ wineberry: '#7e174b',
46
+ 'midnight-teal': '#005d65',
47
+ 'neon-violet': '#7200ff',
48
+ pine: '#006b3c',
49
+ chartreuse: '#c8ff00',
50
+ bronze: '#967400'
51
+ };
16
52
  export function themeAccent(theme) {
53
+ const additionalAccent = additionalThemeAccents[theme];
54
+ if (additionalAccent)
55
+ return additionalAccent;
17
56
  if (theme === 'amber') {
18
57
  return '#ffb000';
19
58
  }
@@ -57,6 +96,13 @@ export function themeAccent(theme) {
57
96
  }
58
97
  export function themeContributionColors(theme) {
59
98
  const emptyContribution = '#1c1c1c';
99
+ const additionalAccent = additionalThemeAccents[theme];
100
+ if (additionalAccent) {
101
+ const channels = [1, 3, 5].map(offset => Number.parseInt(additionalAccent.slice(offset, offset + 2), 16));
102
+ // Blend from the empty cell so dark accents still have visible low levels.
103
+ const shades = [0.25, 0.5, 0.75].map(intensity => `#${channels.map(channel => Math.round(28 + (channel - 28) * intensity).toString(16).padStart(2, '0')).join('')}`);
104
+ return [emptyContribution, ...shades, additionalAccent];
105
+ }
60
106
  if (theme === 'amber') {
61
107
  return [emptyContribution, '#5f3700', '#9a6200', '#d68a00', '#ffb000'];
62
108
  }