Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Requirements:
- Homebrew on macOS: installs RadioCLI, Node.js, and `mpv`
- npm on macOS, Linux, and Windows: Node.js 22 or newer
- `mpv` for best playback; RadioCLI expects one local playback backend at runtime
- `ffmpeg` and the optional `node-airtunes2` peer on macOS for the AirPlay backend
- `ffplay` from FFmpeg as an optional fallback

Recommended macOS install:
Expand All @@ -152,6 +153,19 @@ radiocli
The Homebrew formula depends on `node` and `mpv`, so the native playback backend
comes from the native package manager.

For AirPlay playback on macOS, install FFmpeg and the optional sender peer next
to your npm/source RadioCLI install:

```bash
brew install ffmpeg
npm install node-airtunes2
```

RadioCLI discovers AirPlay/RAOP receivers with Bonjour, decodes streams with
`ffmpeg`, and sends audio directly to the selected receiver when the optional
sender peer is present. Passcode-protected receivers prompt in the TUI; enter the
code with `:airplay-code 1234`.

Universal npm install:

```bash
Expand Down Expand Up @@ -269,7 +283,7 @@ Page-specific footer controls:
| World map | `/` filter, `↑` / `↓` move, `Enter` open country, `w` list, `b` home |
| Station lists | `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
| Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `r` health, `b` home |
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `a` AirPlay, `r` health, `b` home |
| Stats | `b` home |

Other active shortcuts:
Expand Down Expand Up @@ -317,6 +331,7 @@ Useful command palette entries:
:learn play
:learn next
:keys reset
:airplay-code 1234
:map
:library
:stats
Expand All @@ -326,6 +341,11 @@ Useful command palette entries:

Settings persist display colors and receiver styles without editing config files. The fourteen display colors are green, amber, blue, ruby, ice, teal, violet, copper, cyan, lime, coral, rose, slate, and mono, cycled with `t`. The 50 receiver styles span classic receiver displays, high-resolution braille visuals, and generative motion scenes; cycle them with `v` (see the [Demo](#demo) for the full family breakdown). The stats graph and legend follow the selected display color, and the selected Now Playing style is restored on the next launch.

On macOS, Settings can cycle AirPlay targets discovered through Bonjour when
`ffmpeg` and the optional sender peer are installed. Select the `airplay` backend
with `o`, choose a receiver with `a`, then tune a station. If the receiver asks
for a code, enter it with `:airplay-code 1234`.

## Architecture

RadioCLI is split around four seams:
Expand Down
10 changes: 9 additions & 1 deletion apps/docs/content/docs/getting-started/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,20 @@ bun add -g @ciphore/radiocli
The `radiocli` executable still runs on Node.js 22 or newer, even when installed
with pnpm or Bun.

On macOS with npm, install `mpv` first:
On macOS with npm, install `mpv` first. Install FFmpeg and the optional sender
peer too if you want the AirPlay backend:

```bash
brew install mpv
brew install ffmpeg
npm install node-airtunes2
```

RadioCLI discovers AirPlay/RAOP receivers with Bonjour, decodes streams with
`ffmpeg`, and sends audio directly to the selected receiver when the optional
sender peer is present. Passcode-protected receivers prompt in the TUI; enter the
code with `:airplay-code 1234`.

## Linux

On Debian/Ubuntu:
Expand Down
8 changes: 7 additions & 1 deletion apps/docs/content/docs/getting-started/tui-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ volume or mute state, and an active sleep timer.
| World map | `/` filter, `Up` / `Down` move, `Enter` open country, `w` list, `b` home |
| Station lists | `Up` / `Down` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
| Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `r` health, `b` home |
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `a` AirPlay, `r` health, `b` home |
| Stats | `b` home |

Explore mouse clicks use terminal mouse reporting while the Explore tab is
Expand Down Expand Up @@ -61,6 +61,7 @@ Useful command entries:
:learn play
:learn next
:keys reset
:airplay-code 1234
:map
:library
:stats
Expand All @@ -84,3 +85,8 @@ Learn keys from Settings or with `:learn previous`, `:learn play`, and
`:learn next`; clear them with `:keys reset`.

Location behavior is covered in [Privacy and Security](../privacy-security.mdx).

On macOS, Settings can cycle AirPlay targets discovered through Bonjour when
`ffmpeg` and the optional sender peer are installed. Select the `airplay` backend
with `o`, choose a receiver with `a`, then tune a station. If the receiver asks
for a code, enter it with `:airplay-code 1234`.
5 changes: 3 additions & 2 deletions knip.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/smoke/*.ts", "scripts/*.mjs"],
"project": ["src/**/*.ts", "src/**/*.tsx", "scripts/*.mjs"]
"entry": ["src/smoke/*.ts", "src/player/airplay-worker.ts", "scripts/*.mjs"],
"project": ["src/**/*.ts", "src/**/*.tsx", "scripts/*.mjs"],
"ignoreDependencies": ["node-airtunes2"]
}
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@
"react": "^19.2.6",
"zod": "^4.4.3"
},
"peerDependencies": {
"node-airtunes2": "^2.5.0"
},
"peerDependenciesMeta": {
"node-airtunes2": {
"optional": true
}
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
Expand Down
71 changes: 71 additions & 0 deletions src/player/airplay-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {describe, expect, it} from 'vitest';
import {parseRaopBrowseOutput, parseRaopLookupOutput} from './airplay-discovery.js';

describe('AirPlay RAOP discovery parsing', () => {
it('extracts RAOP instance names from dns-sd browse output', () => {
expect(
parseRaopBrowseOutput(`
Browsing for _raop._tcp.local
Timestamp A/R Flags if Domain Service Type Instance Name
23:31:35.326 Add 3 15 local. _raop._tcp. C869CD3DF60C@Living Room
23:31:35.326 Add 3 17 local. _raop._tcp. 5CAAFD0046D4@Office
23:31:35.326 Add 3 15 local. _airplay._tcp. Living Room
23:31:35.326 Add 3 15 local. _raop._tcp. C869CD3DF60C@Living Room
`)
).toEqual(['C869CD3DF60C@Living Room', '5CAAFD0046D4@Office']);
});

it('extracts reachable host, port, name, and flags from dns-sd lookup output', () => {
expect(
parseRaopLookupOutput(
'C869CD3DF60C@Living Room',
`
Lookup C869CD3DF60C@Living Room._raop._tcp.local
23:34:20.710 C869CD3DF60C@Living\\032Room._raop._tcp.local. can be reached at Living-Room.local.:7000 (interface 15)
cn=0,1,2,3 da=true et=0,3,5 ft=0x5A7FDFD5,0x3C155FDE sf=0x18644 md=0,1,2 am=AppleTV5,3 pk=b1a6148b714cd5cf1b20fd1bd7bb9bdf81376e5d4e92318a9088e7db95c1b74f tp=UDP vn=65537 vs=425.1 ov=13.4.8 vv=2
`
)
).toEqual({
id: 'C869CD3DF60C@Living Room',
name: 'Living Room',
host: 'Living-Room.local',
port: 7000,
txt: [
'cn=0,1,2,3',
'da=true',
'et=0,3,5',
'ft=0x5A7FDFD5,0x3C155FDE',
'sf=0x18644',
'md=0,1,2',
'am=AppleTV5,3',
'pk=b1a6148b714cd5cf1b20fd1bd7bb9bdf81376e5d4e92318a9088e7db95c1b74f',
'tp=UDP',
'vn=65537',
'vs=425.1',
'ov=13.4.8',
'vv=2'
],
requiresPassword: true,
airplay2: true
});
});

it('treats plain RAOP receivers without password flags as selectable', () => {
expect(
parseRaopLookupOutput(
'5CAAFD0046D4@Office',
`
23:34:20.584 5CAAFD0046D4@Office._raop._tcp.local. can be reached at Sonos-5CAAFD0046D4.local.:7000 (interface 15)
cn=0,1 da=true et=0,4 ft=0x445F8A00,0x801C340 fv=p20.86.7-77050 md=0,1,2 am=Play:5 sf=0x4 tp=UDP vn=65537 vs=366.0 pk=76c362f4e9378eaab52a88a7f560e0dc31aba7d5b3aafdeed9655522728a313f
`
)
).toMatchObject({
id: '5CAAFD0046D4@Office',
name: 'Office',
host: 'Sonos-5CAAFD0046D4.local',
port: 7000,
requiresPassword: false,
airplay2: true
});
});
});
115 changes: 115 additions & 0 deletions src/player/airplay-discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {spawn} from 'node:child_process';
import type {AirPlayDevice} from '../types.js';
import {commandExists} from './command.js';

export type AirPlayDiscoveryOptions = {
platform?: NodeJS.Platform;
timeoutMs?: number;
};

export async function discoverAirPlayDevices({
platform = process.platform,
timeoutMs = 2500
}: AirPlayDiscoveryOptions = {}): Promise<AirPlayDevice[]> {
if (platform !== 'darwin' || !commandExists('dns-sd')) {
return [];
}

const browseOutput = await runDnsSd(['-B', '_raop._tcp', 'local'], timeoutMs).catch(() => '');
const instances = parseRaopBrowseOutput(browseOutput);
const devices = await Promise.all(
instances.map(async instance => {
const lookupOutput = await runDnsSd(['-L', instance, '_raop._tcp', 'local'], timeoutMs).catch(() => '');
return parseRaopLookupOutput(instance, lookupOutput);
})
);

return devices.filter((device): device is AirPlayDevice => Boolean(device));
}

export function parseRaopBrowseOutput(output: string): string[] {
const instances = new Set<string>();
for (const line of output.split('\n')) {
const match = /\s_raop\._tcp\.\s+(.+)$/.exec(line);
const instance = match?.[1]?.trim();
if (instance) {
instances.add(unescapeDnsSdName(instance));
}
}

return [...instances];
}

export function parseRaopLookupOutput(instance: string, output: string): AirPlayDevice | null {
const reachable = output.match(/ can be reached at (.+?)\.?:(\d+) \(interface \d+\)/);
if (!reachable?.[1] || !reachable[2]) {
return null;
}

const txtLine = output
.split('\n')
.map(line => line.trim())
.find(line => /\bcn=/.test(line) || /\bsf=/.test(line) || /\bpk=/.test(line));
const txt = txtLine ? txtLine.split(/\s+/).filter(Boolean) : [];
const txtMap = txtRecordMap(txt);
const sf = txtMap.get('sf');
const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) : 0;

return {
id: instance,
name: displayNameForRaopInstance(instance),
host: unescapeDnsSdName(reachable[1]).replace(/\.$/, ''),
port: Number(reachable[2]),
txt,
requiresPassword: sfValue !== 0 && sfValue !== 0x4,
airplay2: Boolean(txtMap.get('pk') || txtMap.get('vv') === '2' || txtMap.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value)))
};
}

function displayNameForRaopInstance(instance: string): string {
const atIndex = instance.indexOf('@');
return atIndex === -1 ? instance : instance.slice(atIndex + 1);
}

function txtRecordMap(txt: string[]): Map<string, string> {
const map = new Map<string, string>();
for (const entry of txt) {
const equals = entry.indexOf('=');
if (equals === -1) {
continue;
}

map.set(entry.slice(0, equals), entry.slice(equals + 1));
}

return map;
}

function unescapeDnsSdName(value: string): string {
return value.replace(/\\(\d{3})/g, (_match, code: string) => String.fromCharCode(Number(code)));
}

function runDnsSd(args: string[], timeoutMs: number): Promise<string> {
return new Promise(resolve => {
const child = spawn('dns-sd', args, {stdio: ['ignore', 'pipe', 'pipe']});
let output = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
}, timeoutMs);

child.stdout.on('data', chunk => {
output += chunk.toString('utf8');
});
child.stderr.on('data', chunk => {
output += chunk.toString('utf8');
});
child.once('error', () => {
clearTimeout(timer);
resolve(output);
});
child.once('exit', () => {
clearTimeout(timer);
resolve(output);
});
});
}
30 changes: 30 additions & 0 deletions src/player/airplay-worker-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {describe, expect, it} from 'vitest';
import {decodeWorkerStart, encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage} from './airplay-worker-protocol.js';

describe('AirPlay worker protocol', () => {
it('round-trips worker start payloads through a CLI-safe token', () => {
const start = {
streamUrl: 'https://streams.example.com/live.mp3',
stationName: 'Test FM',
volume: 35,
muted: false,
device: {
id: '5CAAFD0046D4@Office',
name: 'Office',
host: 'Sonos-5CAAFD0046D4.local',
port: 7000,
txt: ['cn=0,1', 'sf=0x4'],
requiresPassword: false,
airplay2: true
}
};

expect(decodeWorkerStart(encodeWorkerStart(start))).toEqual(start);
});

it('serializes line-delimited command and event messages', () => {
expect(parseWorkerMessage(serializeWorkerMessage({type: 'setVolume', volume: 20}))).toEqual({type: 'setVolume', volume: 20});
expect(parseWorkerMessage(serializeWorkerMessage({type: 'password-required'}))).toEqual({type: 'password-required'});
expect(parseWorkerMessage('not json')).toBeNull();
});
});
Loading