@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 +33 -0
- package/CONTRIBUTING.md +18 -0
- package/README.md +3 -3
- package/dist/cli.js +4 -0
- package/dist/storage/file-lock.js +26 -9
- package/dist/storage/store.js +108 -43
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +3 -14
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/theme.js +46 -0
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +1 -0
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +3 -2
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
|
-
[](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml?query=branch%3Amaster+event%3Apush)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](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
|
-
- **
|
|
19
|
+
- **64 receiver styles:** Switch between braille animation, ASCII geometry,
|
|
20
20
|
waves, fire, cityscapes, and the Gallop display without interrupting
|
|
21
|
-
playback.
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
if (codeOf(error) !== 'ENOENT') {
|
|
74
|
+
if (!transient(error))
|
|
75
|
+
throw error;
|
|
76
|
+
retry(error);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
62
79
|
}
|
|
63
|
-
|
|
80
|
+
retry();
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
83
|
function isDead(pid) {
|
package/dist/storage/store.js
CHANGED
|
@@ -18,16 +18,36 @@ const defaultMediaKeys = {
|
|
|
18
18
|
next: []
|
|
19
19
|
};
|
|
20
20
|
const MAX_ALARMS = 500;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
696
|
+
throw storageError('read the display color preference', error);
|
|
651
697
|
}
|
|
652
|
-
const parsed =
|
|
698
|
+
const parsed = themePreferenceSchema.safeParse(JSON.parse(preference));
|
|
653
699
|
if (!parsed.success)
|
|
654
|
-
throw new Error('The RadioCLI
|
|
655
|
-
if (state.settings.
|
|
656
|
-
state.settings.
|
|
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
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
714
|
-
if (
|
|
715
|
-
rmSync(
|
|
778
|
+
for (const preference of applied.reverse()) {
|
|
779
|
+
if (preference.previous === undefined)
|
|
780
|
+
rmSync(preference.path, { force: true });
|
|
716
781
|
else
|
|
717
|
-
writeTextAtomically(
|
|
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 = [
|
|
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(
|
|
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:
|
|
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
|
}
|