@ciphore/radiocli 0.1.5 → 0.1.7
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 +39 -0
- package/README.md +7 -5
- package/dist/cli.js +6 -0
- package/dist/player/backend-install.js +23 -0
- package/dist/player/command.js +92 -4
- package/dist/player/player-controller.js +26 -4
- package/dist/providers/provider-manager.js +3 -0
- package/dist/providers/radio-browser.js +18 -2
- package/dist/storage/store.js +50 -2
- package/dist/ui/App.js +115 -13
- package/dist/ui/AppContent.js +5 -1
- package/dist/ui/app-state.js +10 -1
- package/dist/ui/ascii.js +84 -0
- package/dist/ui/audio-output.js +4 -1
- package/dist/ui/components/ScreenHeader.js +6 -2
- package/dist/ui/components/TopTabs.js +8 -3
- package/dist/ui/display-context.js +25 -0
- package/dist/ui/help-content.js +116 -0
- package/dist/ui/page-footer.js +4 -1
- package/dist/ui/screen-items.js +4 -0
- package/dist/ui/screens/ExploreScreen.js +7 -3
- package/dist/ui/screens/HelpScreen.js +12 -0
- package/dist/ui/screens/MapScreen.js +7 -2
- package/dist/ui/screens/NowPlayingScreen.js +27 -4
- package/dist/ui/screens/SearchScreen.js +4 -2
- package/dist/ui/screens/SettingsScreen.js +8 -0
- package/dist/ui/screens/StatsScreen.js +25 -11
- package/dist/ui/screens/screen-render.test.js +152 -0
- package/dist/ui/system-actions.js +58 -0
- package/dist/ui/use-app-input.js +48 -2
- package/dist/ui/use-command-executor.js +22 -0
- package/dist/version.js +20 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.7] - 2026-06-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Search results can now be navigated with `Up` / `Down` while editing the
|
|
15
|
+
search query; search-history recall remains available with
|
|
16
|
+
`Ctrl+Up` / `Ctrl+Down`.
|
|
17
|
+
- The listening stats activity heatmap now renders day cells as square markers
|
|
18
|
+
and uses a capped contribution scale so one unusually long listening day does
|
|
19
|
+
not flatten the rest of the Less/More color range.
|
|
20
|
+
|
|
21
|
+
## [0.1.6] - 2026-06-17
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Added VLC (`cvlc`/`vlc`) as a third playback-only backend so installs without
|
|
26
|
+
`mpv` or `ffplay` still play audio.
|
|
27
|
+
- Added a Help screen (`?`) listing every keybinding and `:` command, plus
|
|
28
|
+
command-name tab completion in the command palette.
|
|
29
|
+
- Added persisted ICY track history shown on Now Playing (`d`) and up/down
|
|
30
|
+
recall of recent search queries in the search box.
|
|
31
|
+
- Added opt-in resume of the most recent station on launch.
|
|
32
|
+
- Added `O` to open the station homepage and `y` to copy its stream URL.
|
|
33
|
+
- Added ASCII-safe display, transparent background (also honoring `NO_COLOR`),
|
|
34
|
+
and reduce-motion settings for terminal and low-power compatibility.
|
|
35
|
+
- Added a best-effort Radio Browser upvote when favoriting a station.
|
|
36
|
+
- Added a `radiocli version` command (and `--version` / `-v`).
|
|
37
|
+
- Added `:doctor`, `:help`, `:resume`, `:ascii`, `:motion`, and `:background`
|
|
38
|
+
command-palette entries.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Playback binary detection now probes common install locations (Homebrew,
|
|
43
|
+
Scoop, WinGet, Chocolatey, and the VLC app bundle) when a tool is not on
|
|
44
|
+
`PATH`, so GUI-launched terminals still find `mpv`/`ffplay`/VLC.
|
|
45
|
+
- The Radio Browser `User-Agent` now reports the installed package version.
|
|
46
|
+
|
|
10
47
|
## [0.1.5] - 2026-05-31
|
|
11
48
|
|
|
12
49
|
### Fixed
|
|
@@ -119,6 +156,8 @@ Initial public release.
|
|
|
119
156
|
rows go to the visualizer.
|
|
120
157
|
|
|
121
158
|
[0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
|
|
159
|
+
[0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
|
|
160
|
+
[0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
|
|
122
161
|
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
123
162
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
124
163
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -13,8 +13,10 @@ It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://rea
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- Explore public radio from around the world through a cosmo-style braille world map beside the station list, with click-to-place mouse support and WASD keyboard movement backed by a cached geotagged station atlas. Country lists, global station search, a country-density map, and opt-in nearby discovery round out the discovery surface.
|
|
16
|
-
- Tune stations with `mpv` first for full playback controls, with `ffplay` as
|
|
17
|
-
- Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, `mpv`-backed volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
|
|
16
|
+
- Tune stations with `mpv` first for full playback controls, with `ffplay` and VLC (`cvlc`/`vlc`) as playback-only fallbacks. RadioCLI also probes common install locations (Homebrew, Scoop, WinGet, Chocolatey, VLC app bundle) when a backend is not on `PATH`, so GUI-launched terminals still find it.
|
|
17
|
+
- Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata and persisted track history, stream diagnostics, sleep timer, favorite state, `mpv`-backed volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
|
|
18
|
+
- Open the in-app Help screen (`?`) for every keybinding and `:` command, with command-name tab completion and search-history recall. Open a station homepage (`O`) or copy its stream URL (`y`).
|
|
19
|
+
- Adapt to any terminal: ASCII-safe display, transparent background (and `NO_COLOR`) for light terminals, reduce-motion for SSH and low-power devices, and opt-in resume of the last station on launch.
|
|
18
20
|
- Keep shortcuts in a fixed adaptive footer: a compact live station and track row appears above page-specific and global controls while playback is active.
|
|
19
21
|
- Move previous/next through the exact station list you tuned from, even after navigating to another screen.
|
|
20
22
|
- Browse dense station lists with inline location/codec metadata and yellow favorite stars next to station names.
|
|
@@ -75,7 +77,7 @@ Live public radio from around the world
|
|
|
75
77
|
3 recent · 2 favorites · 1 imported
|
|
76
78
|
|
|
77
79
|
↑/↓ move · Enter open · number jump · : command
|
|
78
|
-
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
|
|
80
|
+
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
|
|
79
81
|
```
|
|
80
82
|
|
|
81
83
|
The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
|
|
@@ -106,7 +108,7 @@ Now playing ──────────────────────
|
|
|
106
108
|
|
|
107
109
|
Playing: KEXP 90.3 FM · Now: Artist - Track · Seattle, Washington, United States · MP3 / 128 kbps / english · mpv · playing · vol 70 · Nearby 4/90
|
|
108
110
|
space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home
|
|
109
|
-
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
|
|
111
|
+
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
|
|
110
112
|
```
|
|
111
113
|
|
|
112
114
|
For the exact non-interactive demo transcript:
|
|
@@ -277,7 +279,7 @@ Page-specific footer controls:
|
|
|
277
279
|
| Screen | Controls |
|
|
278
280
|
| --- | --- |
|
|
279
281
|
| Home | `↑` / `↓` move, `Enter` open, number jump, `:` command |
|
|
280
|
-
| Search input | type query, `Backspace` edit, `Enter` search or tune, `Esc` finish |
|
|
282
|
+
| Search input | type query, `Backspace` edit, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Esc` finish |
|
|
281
283
|
| Search results | `/` edit query, `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home |
|
|
282
284
|
| Explore | click map, `WASD` fine move, `Shift+WASD` jump, `↑` / `↓` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
|
|
283
285
|
| Countries | `/` filter, `↑` / `↓` move, `Enter` open stations, `w` map, `b` home |
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { PlayerController } from './player/player-controller.js';
|
|
|
8
8
|
import { JsonLibraryStore } from './storage/store.js';
|
|
9
9
|
import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
|
|
10
10
|
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
11
|
+
import { appVersion } from './version.js';
|
|
11
12
|
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
12
13
|
const args = process.argv.slice(2);
|
|
13
14
|
if (args.length > 0) {
|
|
@@ -33,6 +34,10 @@ export async function runCommand(args) {
|
|
|
33
34
|
printHelp();
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
37
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
38
|
+
console.log(appVersion());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
36
41
|
if (!isKnownCommand(command)) {
|
|
37
42
|
throw new Error(`Unknown command: ${command}\nRun radiocli help.`);
|
|
38
43
|
}
|
|
@@ -113,6 +118,7 @@ export function printHelp() {
|
|
|
113
118
|
|
|
114
119
|
Usage:
|
|
115
120
|
radiocli Start the TUI
|
|
121
|
+
radiocli version Print the installed version
|
|
116
122
|
radiocli check Show provider/backend health
|
|
117
123
|
radiocli doctor Show local playback setup guidance
|
|
118
124
|
radiocli countries Print top countries
|
|
@@ -2,8 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { commandExists } from './command.js';
|
|
3
3
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
4
4
|
export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
|
|
5
|
+
export const vlcLimitedControlsMessage = 'VLC fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
|
|
5
6
|
export function detectPlaybackBackends({ platform = process.platform, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
|
|
6
7
|
const backends = ['mpv', 'ffplay'].filter(hasCommand);
|
|
8
|
+
if (hasCommand('cvlc') || hasCommand('vlc')) {
|
|
9
|
+
backends.push('vlc');
|
|
10
|
+
}
|
|
7
11
|
if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) {
|
|
8
12
|
backends.push('airplay');
|
|
9
13
|
}
|
|
@@ -33,6 +37,16 @@ export function playbackBackendCapabilities(backend) {
|
|
|
33
37
|
supportsMediaKeys: false
|
|
34
38
|
};
|
|
35
39
|
}
|
|
40
|
+
if (backend === 'vlc') {
|
|
41
|
+
return {
|
|
42
|
+
backend,
|
|
43
|
+
label: 'VLC fallback',
|
|
44
|
+
supportsPause: false,
|
|
45
|
+
supportsMute: false,
|
|
46
|
+
supportsVolume: false,
|
|
47
|
+
supportsMediaKeys: false
|
|
48
|
+
};
|
|
49
|
+
}
|
|
36
50
|
if (backend === 'airplay') {
|
|
37
51
|
return {
|
|
38
52
|
backend,
|
|
@@ -82,6 +96,15 @@ export function playbackBackendStatusLines(backends, platform = process.platform
|
|
|
82
96
|
...lines
|
|
83
97
|
];
|
|
84
98
|
}
|
|
99
|
+
if (backendSet.has('vlc')) {
|
|
100
|
+
return [
|
|
101
|
+
'playback=fallback-only',
|
|
102
|
+
'playback_backend=vlc',
|
|
103
|
+
'controls=limited',
|
|
104
|
+
'controls_hint=install mpv for pause, mute, volume, and media keys',
|
|
105
|
+
...lines
|
|
106
|
+
];
|
|
107
|
+
}
|
|
85
108
|
if (backendSet.has('airplay')) {
|
|
86
109
|
return [
|
|
87
110
|
'playback=airplay-only',
|
package/dist/player/command.js
CHANGED
|
@@ -1,8 +1,96 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
export function commandExists(command) {
|
|
6
|
+
return resolveCommand(command) !== null;
|
|
7
|
+
}
|
|
8
|
+
// Resolve a runnable path for a command. GUI-launched terminals (macOS .app
|
|
9
|
+
// terminals, some Linux desktop launchers) frequently do not inherit the login
|
|
10
|
+
// shell PATH, so a perfectly installed binary in /opt/homebrew/bin or a Windows
|
|
11
|
+
// package-manager shim looks "missing" to a bare PATH lookup. We therefore fall
|
|
12
|
+
// back to probing well-known install locations before giving up.
|
|
13
|
+
export function resolveCommand(command) {
|
|
14
|
+
return lookupOnPath(command) ?? probeKnownLocations(command);
|
|
15
|
+
}
|
|
16
|
+
function lookupOnPath(command) {
|
|
3
17
|
const lookup = process.platform === 'win32' ? 'where' : 'which';
|
|
4
|
-
const result = spawnSync(lookup, [command], {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
18
|
+
const result = spawnSync(lookup, [command], { encoding: 'utf8' });
|
|
19
|
+
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const first = result.stdout
|
|
23
|
+
.split(/\r?\n/)
|
|
24
|
+
.map(line => line.trim())
|
|
25
|
+
.find(Boolean);
|
|
26
|
+
return first ?? null;
|
|
27
|
+
}
|
|
28
|
+
function probeKnownLocations(command) {
|
|
29
|
+
for (const candidate of candidatePaths(command)) {
|
|
30
|
+
if (existsSync(candidate)) {
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function candidatePaths(command) {
|
|
37
|
+
const paths = [];
|
|
38
|
+
const names = candidateNames(command);
|
|
39
|
+
for (const dir of knownBinaryDirs()) {
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
paths.push(join(dir, name));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
paths.push(...appBundlePaths(command));
|
|
45
|
+
return paths;
|
|
46
|
+
}
|
|
47
|
+
function candidateNames(command) {
|
|
48
|
+
if (process.platform === 'win32' && !/\.[a-z0-9]+$/i.test(command)) {
|
|
49
|
+
return [`${command}.exe`, `${command}.com`, `${command}.bat`, `${command}.cmd`, command];
|
|
50
|
+
}
|
|
51
|
+
return [command];
|
|
52
|
+
}
|
|
53
|
+
function knownBinaryDirs() {
|
|
54
|
+
const home = homedir();
|
|
55
|
+
if (process.platform === 'darwin') {
|
|
56
|
+
return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', join(home, '.local', 'bin')];
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
const dirs = [];
|
|
60
|
+
if (process.env.LOCALAPPDATA) {
|
|
61
|
+
dirs.push(join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links'));
|
|
62
|
+
}
|
|
63
|
+
dirs.push(join(home, 'scoop', 'shims'));
|
|
64
|
+
if (process.env.ProgramData) {
|
|
65
|
+
dirs.push(join(process.env.ProgramData, 'chocolatey', 'bin'));
|
|
66
|
+
}
|
|
67
|
+
return dirs;
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
'/usr/bin',
|
|
71
|
+
'/usr/local/bin',
|
|
72
|
+
'/bin',
|
|
73
|
+
'/usr/local/sbin',
|
|
74
|
+
'/snap/bin',
|
|
75
|
+
'/var/lib/flatpak/exports/bin',
|
|
76
|
+
join(home, '.local', 'bin')
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
// Some media players (notably VLC) commonly install without putting a CLI entry
|
|
80
|
+
// on PATH, so probe their standard application directories directly.
|
|
81
|
+
function appBundlePaths(command) {
|
|
82
|
+
if (command !== 'vlc' && command !== 'cvlc') {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
if (process.platform === 'darwin') {
|
|
86
|
+
return [
|
|
87
|
+
'/Applications/VLC.app/Contents/MacOS/VLC',
|
|
88
|
+
join(homedir(), 'Applications', 'VLC.app', 'Contents', 'MacOS', 'VLC')
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
if (process.platform === 'win32') {
|
|
92
|
+
const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean);
|
|
93
|
+
return roots.map(root => join(root, 'VideoLAN', 'VLC', 'vlc.exe'));
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
8
96
|
}
|
|
@@ -4,7 +4,8 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { Socket } from 'node:net';
|
|
7
|
-
import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint } from './backend-install.js';
|
|
7
|
+
import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
8
|
+
import { resolveCommand } from './command.js';
|
|
8
9
|
import { discoverAirPlayDevices } from './airplay-discovery.js';
|
|
9
10
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
10
11
|
import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
|
|
@@ -167,6 +168,10 @@ export class PlayerController {
|
|
|
167
168
|
this.playWithFfplay(url);
|
|
168
169
|
await this.waitForReady(backend);
|
|
169
170
|
}
|
|
171
|
+
else if (backend === 'vlc') {
|
|
172
|
+
this.playWithVlc(url);
|
|
173
|
+
await this.waitForReady(backend);
|
|
174
|
+
}
|
|
170
175
|
this.setState({
|
|
171
176
|
backend,
|
|
172
177
|
state: 'playing',
|
|
@@ -300,6 +305,9 @@ export class PlayerController {
|
|
|
300
305
|
if (preferred === 'ffplay') {
|
|
301
306
|
return backends.includes('ffplay') ? 'ffplay' : null;
|
|
302
307
|
}
|
|
308
|
+
if (preferred === 'vlc') {
|
|
309
|
+
return backends.includes('vlc') ? 'vlc' : null;
|
|
310
|
+
}
|
|
303
311
|
if (preferred === 'airplay') {
|
|
304
312
|
return backends.includes('airplay') ? 'airplay' : null;
|
|
305
313
|
}
|
|
@@ -309,6 +317,9 @@ export class PlayerController {
|
|
|
309
317
|
if (backends.includes('ffplay')) {
|
|
310
318
|
return 'ffplay';
|
|
311
319
|
}
|
|
320
|
+
if (backends.includes('vlc')) {
|
|
321
|
+
return 'vlc';
|
|
322
|
+
}
|
|
312
323
|
return null;
|
|
313
324
|
}
|
|
314
325
|
playbackUnavailableMessage() {
|
|
@@ -324,7 +335,7 @@ export class PlayerController {
|
|
|
324
335
|
playWithMpv(url, initialTitle) {
|
|
325
336
|
this.ipcPath = createMpvIpcPath();
|
|
326
337
|
this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
|
|
327
|
-
this.process = spawn('mpv', [
|
|
338
|
+
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
328
339
|
'--no-video',
|
|
329
340
|
'--really-quiet',
|
|
330
341
|
'--force-window=no',
|
|
@@ -338,11 +349,19 @@ export class PlayerController {
|
|
|
338
349
|
this.wireProcess();
|
|
339
350
|
}
|
|
340
351
|
playWithFfplay(url) {
|
|
341
|
-
this.process = spawn('ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
352
|
+
this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
342
353
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
343
354
|
});
|
|
344
355
|
this.wireProcess();
|
|
345
356
|
}
|
|
357
|
+
playWithVlc(url) {
|
|
358
|
+
// VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
|
|
359
|
+
// binary (resolved from app bundles when off PATH) with a dummy interface.
|
|
360
|
+
const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
|
|
361
|
+
const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2);
|
|
362
|
+
this.process = spawn(binary, ['--intf', 'dummy', '--no-video', '--quiet', '--play-and-exit', `--gain=${gain}`, url], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
363
|
+
this.wireProcess();
|
|
364
|
+
}
|
|
346
365
|
canRetuneAirPlay(device) {
|
|
347
366
|
return Boolean(this.backend === 'airplay' &&
|
|
348
367
|
this.process &&
|
|
@@ -555,6 +574,9 @@ export class PlayerController {
|
|
|
555
574
|
if (this.backend === 'ffplay' && this.process) {
|
|
556
575
|
return { ok: false, message: ffplayLimitedControlsMessage };
|
|
557
576
|
}
|
|
577
|
+
if (this.backend === 'vlc' && this.process) {
|
|
578
|
+
return { ok: false, message: vlcLimitedControlsMessage };
|
|
579
|
+
}
|
|
558
580
|
return null;
|
|
559
581
|
}
|
|
560
582
|
wireProcess() {
|
|
@@ -708,7 +730,7 @@ export class PlayerController {
|
|
|
708
730
|
if (!this.process) {
|
|
709
731
|
throw new Error('Player exited before the stream became ready.');
|
|
710
732
|
}
|
|
711
|
-
if (backend === 'ffplay') {
|
|
733
|
+
if (backend === 'ffplay' || backend === 'vlc') {
|
|
712
734
|
await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
|
|
713
735
|
return;
|
|
714
736
|
}
|
|
@@ -37,6 +37,9 @@ export class ProviderManager {
|
|
|
37
37
|
}
|
|
38
38
|
return this.radioBrowser.resolve(station);
|
|
39
39
|
}
|
|
40
|
+
vote(station) {
|
|
41
|
+
return this.radioBrowser.vote(station);
|
|
42
|
+
}
|
|
40
43
|
async health(settings) {
|
|
41
44
|
const radioBrowser = await this.radioBrowser.health().catch(error => error instanceof Error ? error.message : 'unavailable');
|
|
42
45
|
const radioGarden = settings.enableRadioGarden
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ProviderCache } from './cache.js';
|
|
3
|
+
import { userAgent } from '../version.js';
|
|
3
4
|
const stationSchema = z.object({
|
|
4
5
|
stationuuid: z.string(),
|
|
5
6
|
name: z.string().default('Untitled station'),
|
|
@@ -164,13 +165,28 @@ export class RadioBrowserProvider {
|
|
|
164
165
|
}
|
|
165
166
|
return { url: result.url, name: result.name };
|
|
166
167
|
}
|
|
168
|
+
// Give back to the directory: an upvote on favorite helps surface good
|
|
169
|
+
// stations for everyone. Radio Browser rate-limits votes per IP, so this is
|
|
170
|
+
// best-effort and never blocks the favorite action.
|
|
171
|
+
async vote(station) {
|
|
172
|
+
if (station.provider !== this.id) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const result = clickResolveSchema.parse(await this.request(`/json/vote/${encodeURIComponent(station.id)}`, {}, { timeoutMs: 5000 }));
|
|
177
|
+
return result.ok;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
167
183
|
async detectLocation() {
|
|
168
184
|
const controller = new AbortController();
|
|
169
185
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
170
186
|
try {
|
|
171
187
|
const response = await fetch('https://ipapi.co/json/', {
|
|
172
188
|
signal: controller.signal,
|
|
173
|
-
headers: { 'User-Agent':
|
|
189
|
+
headers: { 'User-Agent': userAgent() }
|
|
174
190
|
});
|
|
175
191
|
if (!response.ok) {
|
|
176
192
|
return null;
|
|
@@ -306,7 +322,7 @@ async function fetchWithTimeout(url, timeoutMs) {
|
|
|
306
322
|
return await fetch(url, {
|
|
307
323
|
signal: controller.signal,
|
|
308
324
|
headers: {
|
|
309
|
-
'User-Agent': '
|
|
325
|
+
'User-Agent': userAgent(' (+https://radio-browser.info)'),
|
|
310
326
|
Accept: 'application/json'
|
|
311
327
|
}
|
|
312
328
|
});
|
package/dist/storage/store.js
CHANGED
|
@@ -136,7 +136,7 @@ const settingsSchema = z.object({
|
|
|
136
136
|
volume: z.number().min(0).max(100).default(70),
|
|
137
137
|
enableRadioGarden: z.boolean().default(false),
|
|
138
138
|
enableNearbyLocation: z.boolean().default(false),
|
|
139
|
-
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'airplay']).default('auto'),
|
|
139
|
+
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
|
|
140
140
|
preferredAirPlayDevice: z.string().min(1).optional(),
|
|
141
141
|
tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
|
|
142
142
|
skipBrokenStreams: z.boolean().default(true),
|
|
@@ -146,7 +146,11 @@ const settingsSchema = z.object({
|
|
|
146
146
|
playPause: z.array(z.string()).default([]),
|
|
147
147
|
next: z.array(z.string()).default([])
|
|
148
148
|
})
|
|
149
|
-
.default(defaultMediaKeys)
|
|
149
|
+
.default(defaultMediaKeys),
|
|
150
|
+
resumeOnLaunch: z.boolean().default(false),
|
|
151
|
+
transparentBackground: z.boolean().default(false),
|
|
152
|
+
asciiMode: z.boolean().default(false),
|
|
153
|
+
reduceMotion: z.boolean().default(false)
|
|
150
154
|
});
|
|
151
155
|
const librarySchema = z.object({
|
|
152
156
|
recent: z
|
|
@@ -157,6 +161,15 @@ const librarySchema = z.object({
|
|
|
157
161
|
.default([]),
|
|
158
162
|
favorites: z.array(stationSchema).default([]),
|
|
159
163
|
imported: z.array(stationSchema).default([]),
|
|
164
|
+
trackHistory: z
|
|
165
|
+
.array(z.object({
|
|
166
|
+
title: z.string(),
|
|
167
|
+
stationKey: z.string(),
|
|
168
|
+
stationName: z.string(),
|
|
169
|
+
at: z.string()
|
|
170
|
+
}))
|
|
171
|
+
.default([]),
|
|
172
|
+
searchHistory: z.array(z.string()).default([]),
|
|
160
173
|
activity: z
|
|
161
174
|
.object({
|
|
162
175
|
sessions: z
|
|
@@ -215,6 +228,39 @@ export class JsonLibraryStore {
|
|
|
215
228
|
this.write();
|
|
216
229
|
return this.snapshot();
|
|
217
230
|
}
|
|
231
|
+
// Persist the ICY track titles a station announces so "what was that song?"
|
|
232
|
+
// is answerable later. Consecutive duplicates on the same station are skipped.
|
|
233
|
+
recordTrack(station, title) {
|
|
234
|
+
const cleaned = title.trim();
|
|
235
|
+
if (!cleaned) {
|
|
236
|
+
return this.snapshot();
|
|
237
|
+
}
|
|
238
|
+
const key = stationKey(station);
|
|
239
|
+
const last = this.state.trackHistory[0];
|
|
240
|
+
if (last && last.title === cleaned && last.stationKey === key) {
|
|
241
|
+
return this.snapshot();
|
|
242
|
+
}
|
|
243
|
+
const entry = {
|
|
244
|
+
title: cleaned,
|
|
245
|
+
stationKey: key,
|
|
246
|
+
stationName: station.name,
|
|
247
|
+
at: new Date().toISOString()
|
|
248
|
+
};
|
|
249
|
+
this.state = { ...this.state, trackHistory: [entry, ...this.state.trackHistory].slice(0, 100) };
|
|
250
|
+
this.write();
|
|
251
|
+
return this.snapshot();
|
|
252
|
+
}
|
|
253
|
+
// Most-recent-first search queries for up-arrow recall in the search box.
|
|
254
|
+
addSearch(query) {
|
|
255
|
+
const cleaned = query.trim();
|
|
256
|
+
if (!cleaned) {
|
|
257
|
+
return this.snapshot();
|
|
258
|
+
}
|
|
259
|
+
const searchHistory = [cleaned, ...this.state.searchHistory.filter(item => item !== cleaned)].slice(0, 30);
|
|
260
|
+
this.state = { ...this.state, searchHistory };
|
|
261
|
+
this.write();
|
|
262
|
+
return this.snapshot();
|
|
263
|
+
}
|
|
218
264
|
startListeningSession(station, startedAt = new Date()) {
|
|
219
265
|
this.finishActiveListeningSession(startedAt);
|
|
220
266
|
const session = {
|
|
@@ -329,6 +375,8 @@ function defaultState() {
|
|
|
329
375
|
recent: [],
|
|
330
376
|
favorites: [],
|
|
331
377
|
imported: [],
|
|
378
|
+
trackHistory: [],
|
|
379
|
+
searchHistory: [],
|
|
332
380
|
activity: { sessions: [] },
|
|
333
381
|
settings: {
|
|
334
382
|
theme: 'green',
|