From 976bcf4f8adf372dd02a7621a2a8f131e0904c42 Mon Sep 17 00:00:00 2001 From: Sebastian B Date: Sat, 30 May 2026 23:57:11 +0100 Subject: [PATCH] feat: add airplay playback backend --- README.md | 22 ++- .../content/docs/getting-started/install.mdx | 10 +- .../docs/getting-started/tui-controls.mdx | 8 +- knip.json | 5 +- package-lock.json | 8 + package.json | 8 + src/player/airplay-discovery.test.ts | 71 +++++++ src/player/airplay-discovery.ts | 115 +++++++++++ src/player/airplay-worker-protocol.test.ts | 30 +++ src/player/airplay-worker-protocol.ts | 48 +++++ src/player/airplay-worker.ts | 177 +++++++++++++++++ src/player/backend-install.test.ts | 10 +- src/player/backend-install.ts | 39 +++- src/player/player-controller.test.ts | 52 ++++- src/player/player-controller.ts | 178 +++++++++++++++++- src/storage/store.test.ts | 38 +++- src/storage/store.ts | 3 +- src/types.ts | 13 +- src/ui/App.tsx | 25 ++- src/ui/AppContent.tsx | 4 + src/ui/app-state.test.ts | 22 +++ src/ui/app-state.ts | 26 ++- src/ui/page-footer.ts | 2 +- src/ui/screen-items.ts | 1 + src/ui/screens/SettingsScreen.tsx | 13 +- src/ui/use-app-input.ts | 9 + src/ui/use-command-executor.ts | 10 + 27 files changed, 919 insertions(+), 28 deletions(-) create mode 100644 src/player/airplay-discovery.test.ts create mode 100644 src/player/airplay-discovery.ts create mode 100644 src/player/airplay-worker-protocol.test.ts create mode 100644 src/player/airplay-worker-protocol.ts create mode 100644 src/player/airplay-worker.ts diff --git a/README.md b/README.md index a4d0113..46e29d9 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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: @@ -317,6 +331,7 @@ Useful command palette entries: :learn play :learn next :keys reset +:airplay-code 1234 :map :library :stats @@ -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: diff --git a/apps/docs/content/docs/getting-started/install.mdx b/apps/docs/content/docs/getting-started/install.mdx index 889af5d..c1aaccd 100644 --- a/apps/docs/content/docs/getting-started/install.mdx +++ b/apps/docs/content/docs/getting-started/install.mdx @@ -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: diff --git a/apps/docs/content/docs/getting-started/tui-controls.mdx b/apps/docs/content/docs/getting-started/tui-controls.mdx index 31db424..c385229 100644 --- a/apps/docs/content/docs/getting-started/tui-controls.mdx +++ b/apps/docs/content/docs/getting-started/tui-controls.mdx @@ -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 @@ -61,6 +61,7 @@ Useful command entries: :learn play :learn next :keys reset +:airplay-code 1234 :map :library :stats @@ -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`. diff --git a/knip.json b/knip.json index 450ac51..f4ca5e0 100644 --- a/knip.json +++ b/knip.json @@ -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"] } diff --git a/package-lock.json b/package-lock.json index f251424..0185411 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,14 @@ }, "engines": { "node": ">=22" + }, + "peerDependencies": { + "node-airtunes2": "^2.5.0" + }, + "peerDependenciesMeta": { + "node-airtunes2": { + "optional": true + } } }, "node_modules/@alcalzone/ansi-tokenize": { diff --git a/package.json b/package.json index 73c559e..3b6e73d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/player/airplay-discovery.test.ts b/src/player/airplay-discovery.test.ts new file mode 100644 index 0000000..341f170 --- /dev/null +++ b/src/player/airplay-discovery.test.ts @@ -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 + }); + }); +}); diff --git a/src/player/airplay-discovery.ts b/src/player/airplay-discovery.ts new file mode 100644 index 0000000..e631ba5 --- /dev/null +++ b/src/player/airplay-discovery.ts @@ -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 { + 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(); + 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 { + const map = new Map(); + 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 { + 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); + }); + }); +} diff --git a/src/player/airplay-worker-protocol.test.ts b/src/player/airplay-worker-protocol.test.ts new file mode 100644 index 0000000..b8557e8 --- /dev/null +++ b/src/player/airplay-worker-protocol.test.ts @@ -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(); + }); +}); diff --git a/src/player/airplay-worker-protocol.ts b/src/player/airplay-worker-protocol.ts new file mode 100644 index 0000000..7bb372a --- /dev/null +++ b/src/player/airplay-worker-protocol.ts @@ -0,0 +1,48 @@ +import type {AirPlayDevice} from '../types.js'; + +export type AirPlayWorkerStart = { + streamUrl: string; + stationName: string; + volume: number; + muted: boolean; + device: AirPlayDevice; +}; + +export type AirPlayWorkerCommand = + | {type: 'stop'} + | {type: 'setVolume'; volume: number} + | {type: 'setMuted'; muted: boolean} + | {type: 'passcode'; code: string}; + +export type AirPlayWorkerEvent = + | {type: 'ready'} + | {type: 'playing'} + | {type: 'buffer'; status: string} + | {type: 'password-required'} + | {type: 'stopped'} + | {type: 'error'; message: string}; + +export function encodeWorkerStart(start: AirPlayWorkerStart): string { + return Buffer.from(JSON.stringify(start), 'utf8').toString('base64url'); +} + +export function decodeWorkerStart(encoded: string): AirPlayWorkerStart { + return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as AirPlayWorkerStart; +} + +export function serializeWorkerMessage(message: AirPlayWorkerCommand | AirPlayWorkerEvent): string { + return `${JSON.stringify(message)}\n`; +} + +export function parseWorkerMessage(line: string): T | null { + if (!line.trim()) { + return null; + } + + try { + const parsed = JSON.parse(line) as T; + return parsed && typeof parsed === 'object' && 'type' in parsed ? parsed : null; + } catch { + return null; + } +} diff --git a/src/player/airplay-worker.ts b/src/player/airplay-worker.ts new file mode 100644 index 0000000..ec9e0c4 --- /dev/null +++ b/src/player/airplay-worker.ts @@ -0,0 +1,177 @@ +import {spawn} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {parseWorkerMessage, decodeWorkerStart, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; + +const require = createRequire(import.meta.url); + +// node-airtunes2 writes diagnostics with console.log; keep stdout reserved for JSON events. +console.log = (...args: unknown[]) => { + process.stderr.write(`${args.map(String).join(' ')}\n`); +}; + +type AirTunesClient = { + add: (host: string, options: Record) => {key: string; setPasscode?: (code: string) => void}; + write: (chunk: Buffer) => void; + stopAll: (callback?: () => void) => void; + setVolume: (key: string, volume: number, callback?: () => void) => void; + on: (event: 'device' | 'buffer' | 'error', listener: (...args: unknown[]) => void) => void; +}; + +const AirTunes = loadAirTunesSender(); +const encodedStart = process.argv[2]; + +if (!encodedStart) { + emit({type: 'error', message: 'Missing AirPlay worker start payload.'}); + process.exit(1); +} + +const start = decodeWorkerStart(encodedStart); +const airtunes = new AirTunes(); +let deviceKey = ''; +let muted = start.muted; +let volume = start.volume; +let stopping = false; + +airtunes.on('device', (key, status) => { + if (typeof key === 'string') { + deviceKey = key; + } + + if (status === 'ready') { + emit({type: 'ready'}); + } else if (status === 'playing' || status === 'pair_success') { + emit({type: 'playing'}); + } else if (status === 'need_password') { + emit({type: 'password-required'}); + } else if (status === 'stopped') { + emit({type: 'stopped'}); + } else if (status === 'error') { + emit({type: 'error', message: 'AirPlay receiver reported an error.'}); + } +}); + +airtunes.on('buffer', status => { + emit({type: 'buffer', status: String(status)}); + if (status === 'playing') { + emit({type: 'playing'}); + } +}); + +airtunes.on('error', error => { + emit({type: 'error', message: error instanceof Error ? error.message : String(error)}); +}); + +const device = airtunes.add(start.device.host, { + port: start.device.port, + volume: muted ? 0 : volume, + txt: start.device.txt, + airplay2: start.device.airplay2, + forceAlac: true, + debug: false, + mode: start.device.airplay2 ? 2 : 0 +}); +deviceKey = device.key; + +const ffmpeg = spawn('ffmpeg', [ + '-hide_banner', + '-loglevel', + 'error', + '-i', + start.streamUrl, + '-f', + 's16le', + '-ac', + '2', + '-ar', + '44100', + 'pipe:1' +], { + stdio: ['ignore', 'pipe', 'pipe'] +}); + +ffmpeg.stdout.on('data', chunk => { + airtunes.write(chunk as Buffer); +}); +ffmpeg.stderr.on('data', chunk => { + process.stderr.write(chunk); +}); +ffmpeg.once('error', error => { + emit({type: 'error', message: error.message}); + stop(1); +}); +ffmpeg.once('exit', code => { + if (!stopping && code !== 0) { + emit({type: 'error', message: `ffmpeg exited with code ${code}`}); + } +}); + +let stdinBuffer = ''; +process.stdin.on('data', chunk => { + stdinBuffer += chunk.toString('utf8'); + let newlineIndex = stdinBuffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = stdinBuffer.slice(0, newlineIndex); + stdinBuffer = stdinBuffer.slice(newlineIndex + 1); + newlineIndex = stdinBuffer.indexOf('\n'); + const command = parseWorkerMessage(line); + if (command) { + handleCommand(command); + } + } +}); + +process.once('SIGTERM', () => stop(0)); +process.once('SIGINT', () => stop(0)); + +function handleCommand(command: AirPlayWorkerCommand): void { + if (command.type === 'stop') { + stop(0); + } else if (command.type === 'setVolume') { + volume = clampVolume(command.volume); + setAirPlayVolume(muted ? 0 : volume); + } else if (command.type === 'setMuted') { + muted = command.muted; + setAirPlayVolume(muted ? 0 : volume); + } else if (command.type === 'passcode') { + device.setPasscode?.(command.code); + } +} + +function setAirPlayVolume(nextVolume: number): void { + if (deviceKey) { + airtunes.setVolume(deviceKey, nextVolume); + } +} + +function emit(event: AirPlayWorkerEvent): void { + process.stdout.write(serializeWorkerMessage(event)); +} + +function loadAirTunesSender(): new () => AirTunesClient { + try { + return require('node-airtunes2') as new () => AirTunesClient; + } catch { + emit({type: 'error', message: 'AirPlay sender package not installed. Install optional peer node-airtunes2 next to RadioCLI.'}); + process.exit(1); + } +} + +function stop(code: number): void { + if (stopping) { + return; + } + + stopping = true; + if (!ffmpeg.killed) { + ffmpeg.kill('SIGTERM'); + } + + airtunes.stopAll(() => { + emit({type: 'stopped'}); + process.exit(code); + }); +} + +function clampVolume(nextVolume: number): number { + return Math.min(100, Math.max(0, Math.round(nextVolume))); +} diff --git a/src/player/backend-install.test.ts b/src/player/backend-install.test.ts index e67f648..02a2bf8 100644 --- a/src/player/backend-install.test.ts +++ b/src/player/backend-install.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {mpvInstallCommand, playbackBackendStatusLines} from './backend-install.js'; +import {detectPlaybackBackends, mpvInstallCommand, playbackBackendStatusLines} from './backend-install.js'; describe('playback backend install guidance', () => { it('uses Homebrew for macOS mpv guidance', () => { @@ -47,4 +47,12 @@ describe('playback backend install guidance', () => { 'optional_ffplay=brew install ffmpeg' ]); }); + + it('only reports AirPlay when macOS tools and the optional sender package are available', () => { + const hasCommand = (command: string): boolean => ['ffmpeg', 'dns-sd'].includes(command); + + expect(detectPlaybackBackends({platform: 'darwin', hasCommand, hasAirPlaySender: () => false})).toEqual([]); + expect(detectPlaybackBackends({platform: 'darwin', hasCommand, hasAirPlaySender: () => true})).toEqual(['airplay']); + expect(detectPlaybackBackends({platform: 'linux', hasCommand, hasAirPlaySender: () => true})).toEqual([]); + }); }); diff --git a/src/player/backend-install.ts b/src/player/backend-install.ts index 25977e8..7dc4894 100644 --- a/src/player/backend-install.ts +++ b/src/player/backend-install.ts @@ -1,10 +1,35 @@ import {existsSync, readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; import {commandExists} from './command.js'; -const playbackBackends = ['mpv', 'ffplay'] as const; +const require = createRequire(import.meta.url); -export function detectPlaybackBackends(): string[] { - return playbackBackends.filter(commandExists); +type PlaybackBackendDetectionOptions = { + platform?: NodeJS.Platform; + hasCommand?: (command: string) => boolean; + hasAirPlaySender?: () => boolean; +}; + +export function detectPlaybackBackends({ + platform = process.platform, + hasCommand = commandExists, + hasAirPlaySender = hasAirPlaySenderPackage +}: PlaybackBackendDetectionOptions = {}): string[] { + const backends = ['mpv', 'ffplay'].filter(hasCommand); + if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) { + backends.push('airplay'); + } + + return backends; +} + +function hasAirPlaySenderPackage(): boolean { + try { + require.resolve('node-airtunes2'); + return true; + } catch { + return false; + } } export function playbackBackendInstallHint( @@ -42,6 +67,14 @@ export function playbackBackendStatusLines( ]; } + if (backendSet.has('airplay')) { + return [ + 'playback=airplay-only', + 'playback_backend=airplay', + ...lines + ]; + } + return [ 'playback=missing', 'playback_backend=none', diff --git a/src/player/player-controller.test.ts b/src/player/player-controller.test.ts index 639a527..1969098 100644 --- a/src/player/player-controller.test.ts +++ b/src/player/player-controller.test.ts @@ -4,8 +4,9 @@ import {existsSync, unlinkSync} from 'node:fs'; import {createServer, type Server} from 'node:net'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {commandExists} from './command.js'; +import {discoverAirPlayDevices} from './airplay-discovery.js'; import {createMpvIpcPath, extractMpvTitle, PlayerController} from './player-controller.js'; -import type {AppSettings, Station} from '../types.js'; +import type {AirPlayDevice, AppSettings, Station} from '../types.js'; vi.mock('node:child_process', () => ({ spawn: vi.fn() @@ -15,8 +16,13 @@ vi.mock('./command.js', () => ({ commandExists: vi.fn() })); +vi.mock('./airplay-discovery.js', () => ({ + discoverAirPlayDevices: vi.fn() +})); + const spawnMock = vi.mocked(spawn); const commandExistsMock = vi.mocked(commandExists); +const discoverAirPlayDevicesMock = vi.mocked(discoverAirPlayDevices); afterEach(() => { vi.clearAllMocks(); @@ -128,6 +134,27 @@ describe('PlayerController lifecycle', () => { unsubscribe(); }); + it('starts the AirPlay worker and forwards passcodes', async () => { + commandExistsMock.mockImplementation(command => ['ffmpeg', 'dns-sd'].includes(command)); + discoverAirPlayDevicesMock.mockResolvedValue([airPlayDevice()]); + const child = fakeChildProcess(); + spawnMock.mockReturnValue(child as never); + const controller = new PlayerController(() => settings({preferredBackend: 'airplay', preferredAirPlayDevice: '5CAAFD0046D4@Office'})); + setDetectedBackends(controller, ['airplay']); + + const playing = controller.play(station(), 'https://streams.example.com/live.mp3'); + await waitUntil(() => spawnMock.mock.calls.length === 1); + child.stdout.emit('data', '{"type":"ready"}\n'); + await playing; + + expect(spawnMock.mock.calls[0]?.[0]).toBe(process.execPath); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(expect.arrayContaining([expect.stringContaining('airplay-worker')])); + expect(controller.getState()).toMatchObject({backend: 'airplay', state: 'playing', ready: true}); + + controller.submitAirPlayPasscode('1234'); + expect(child.stdin.write).toHaveBeenCalledWith('{"type":"passcode","code":"1234"}\n'); + }); + it('reconciles mpv pause toggles against the backend state', async () => { commandExistsMock.mockImplementation(command => command === 'mpv'); const child = fakeChildProcess(); @@ -243,17 +270,40 @@ function station(overrides: Partial = {}): Station { }; } +function airPlayDevice(overrides: Partial = {}): AirPlayDevice { + return { + id: '5CAAFD0046D4@Office', + name: 'Office', + host: 'Sonos-5CAAFD0046D4.local', + port: 7000, + txt: ['cn=0,1', 'sf=0x4'], + requiresPassword: false, + airplay2: true, + ...overrides + }; +} + +function setDetectedBackends(controller: PlayerController, backends: string[]): void { + (controller as unknown as {availableBackends: string[]}).availableBackends = backends; +} + function fakeChildProcess(): EventEmitter & { stdin: {write: ReturnType}; + stdout: EventEmitter; + stderr: EventEmitter; killed: boolean; kill: ReturnType; } { const child = new EventEmitter() as EventEmitter & { stdin: {write: ReturnType}; + stdout: EventEmitter; + stderr: EventEmitter; killed: boolean; kill: ReturnType; }; child.stdin = {write: vi.fn()}; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); child.killed = false; child.kill = vi.fn(() => { child.killed = true; diff --git a/src/player/player-controller.ts b/src/player/player-controller.ts index 2230864..9e20e4d 100644 --- a/src/player/player-controller.ts +++ b/src/player/player-controller.ts @@ -1,17 +1,20 @@ import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process'; import {existsSync, unlinkSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {dirname, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; import {Socket} from 'node:net'; -import type {AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; +import type {AirPlayDevice, AppSettings, IcyNowPlaying, PlaybackDiagnostics, PlaybackState, Station} from '../types.js'; import {detectPlaybackBackends, playbackBackendInstallHint} from './backend-install.js'; +import {discoverAirPlayDevices} from './airplay-discovery.js'; +import {encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage, type AirPlayWorkerCommand, type AirPlayWorkerEvent} from './airplay-worker-protocol.js'; export type PlayerEvent = (state: PlaybackState) => void; export type MetadataEvent = (metadata: IcyNowPlaying) => void; export class PlayerController { private process: ChildProcessWithoutNullStreams | null = null; - private backend: 'mpv' | 'ffplay' | null = null; + private backend: 'mpv' | 'ffplay' | 'airplay' | null = null; private ipcPath: string | null = null; private metadataTimer: NodeJS.Timeout | null = null; private playbackStateTimer: NodeJS.Timeout | null = null; @@ -19,6 +22,9 @@ export class PlayerController { private listeners = new Set(); private metadataListeners = new Set(); private availableBackends: string[] | null = null; + private availableAirPlayDevices: AirPlayDevice[] = []; + private airPlayReadyResolver: ((result: 'ready' | 'password-required') => void) | null = null; + private airPlayReadyRejecter: ((error: Error) => void) | null = null; private nextMpvRequestId = 1; private currentMpvMediaTitle: string | null = null; @@ -81,8 +87,27 @@ export class PlayerController { streamUrl: url, ready: false }); - backend === 'mpv' ? this.playWithMpv(url, station.name) : this.playWithFfplay(url); - await this.waitForReady(backend); + if (backend === 'mpv') { + this.playWithMpv(url, station.name); + await this.waitForReady(backend); + } else if (backend === 'ffplay') { + this.playWithFfplay(url); + await this.waitForReady(backend); + } else { + const device = await this.resolveAirPlayDevice(); + const result = await this.playWithAirPlay(url, station.name, device); + if (result === 'password-required') { + this.setState({ + ...this.state, + backend, + state: 'loading', + message: 'AirPlay code required. Use :airplay-code 1234.', + ready: false + }); + return; + } + } + this.setState({ backend, state: 'playing', @@ -111,7 +136,9 @@ export class PlayerController { return; } } else { - this.process.stdin.write('p'); + if (this.backend === 'ffplay') { + this.process.stdin.write('p'); + } } this.setState({ @@ -131,6 +158,8 @@ export class PlayerController { for (let index = 0; index < steps; index += 1) { this.process.stdin.write(key); } + } else if (this.backend === 'airplay') { + this.sendAirPlayCommand({type: 'setVolume', volume: clamped}); } this.setState({...this.state, volume: clamped}); @@ -146,6 +175,8 @@ export class PlayerController { await this.sendMpv({command: ['set_property', 'mute', muted]}).catch(() => undefined); } else if (this.backend === 'ffplay' && this.process) { this.process.stdin.write('m'); + } else if (this.backend === 'airplay') { + this.sendAirPlayCommand({type: 'setMuted', muted}); } this.setState({...this.state, muted}); @@ -155,6 +186,8 @@ export class PlayerController { this.stopMpvPolling(); if (this.backend === 'mpv') { await this.sendMpv({command: ['quit']}).catch(() => undefined); + } else if (this.backend === 'airplay') { + this.sendAirPlayCommand({type: 'stop'}); } if (this.process && !this.process.killed) { @@ -171,7 +204,25 @@ export class PlayerController { }); } - private selectBackend(): 'mpv' | 'ffplay' | null { + submitAirPlayPasscode(code: string): void { + if (this.backend !== 'airplay') { + return; + } + + this.sendAirPlayCommand({type: 'passcode', code}); + this.setState({...this.state, message: 'AirPlay code sent.'}); + } + + async refreshAirPlayDevices(): Promise { + this.availableAirPlayDevices = await discoverAirPlayDevices(); + return [...this.availableAirPlayDevices]; + } + + detectedAirPlayDevices(): AirPlayDevice[] { + return [...this.availableAirPlayDevices]; + } + + private selectBackend(): 'mpv' | 'ffplay' | 'airplay' | null { const preferred = this.getSettings().preferredBackend; const backends = this.availableBackends ?? this.refreshDetectedBackends(); if (preferred === 'mpv') { @@ -182,6 +233,10 @@ export class PlayerController { return backends.includes('ffplay') ? 'ffplay' : null; } + if (preferred === 'airplay') { + return backends.includes('airplay') ? 'airplay' : null; + } + if (backends.includes('mpv')) { return 'mpv'; } @@ -190,6 +245,10 @@ export class PlayerController { return 'ffplay'; } + if (backends.includes('airplay')) { + return 'airplay'; + } + return null; } @@ -221,6 +280,91 @@ export class PlayerController { this.wireProcess(); } + private async resolveAirPlayDevice(): Promise { + const devices = await this.refreshAirPlayDevices(); + const preferred = this.getSettings().preferredAirPlayDevice; + const device = devices.find(candidate => candidate.id === preferred) ?? devices[0]; + if (!device) { + throw new Error('No AirPlay receiver found. Make sure the receiver is on the same network.'); + } + + return device; + } + + private playWithAirPlay(url: string, stationName: string, device: AirPlayDevice): Promise<'ready' | 'password-required'> { + const workerPath = airPlayWorkerPath(); + const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({ + streamUrl: url, + stationName, + volume: this.getSettings().volume, + muted: false, + device + })); + this.process = spawn(process.execPath, workerArgs, { + stdio: ['pipe', 'pipe', 'pipe'] + }); + this.wireAirPlayProcess(); + this.wireProcess(); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.airPlayReadyResolver = null; + this.airPlayReadyRejecter = null; + reject(new Error(`Timed out while opening AirPlay stream after ${this.getSettings().tuneTimeoutSeconds}s.`)); + }, this.getSettings().tuneTimeoutSeconds * 1000); + this.airPlayReadyResolver = result => { + clearTimeout(timeout); + this.airPlayReadyResolver = null; + this.airPlayReadyRejecter = null; + resolve(result); + }; + this.airPlayReadyRejecter = error => { + clearTimeout(timeout); + this.airPlayReadyResolver = null; + this.airPlayReadyRejecter = null; + reject(error); + }; + }); + } + + private wireAirPlayProcess(): void { + const child = this.process; + if (!child) { + return; + } + + let buffer = ''; + child.stdout.on('data', chunk => { + buffer += chunk.toString('utf8'); + let newlineIndex = buffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf('\n'); + const event = parseWorkerMessage(line); + if (event) { + this.handleAirPlayEvent(event); + } + } + }); + } + + private handleAirPlayEvent(event: AirPlayWorkerEvent): void { + if (event.type === 'ready' || event.type === 'playing') { + this.airPlayReadyResolver?.('ready'); + if (this.backend === 'airplay') { + this.setState({...this.state, backend: 'airplay', state: 'playing', ready: true, message: this.state.stationName}); + } + } else if (event.type === 'password-required') { + this.airPlayReadyResolver?.('password-required'); + this.setState({...this.state, backend: 'airplay', state: 'loading', ready: false, message: 'AirPlay code required. Use :airplay-code 1234.'}); + } else if (event.type === 'error') { + const error = new Error(event.message); + this.airPlayReadyRejecter?.(error); + this.setState({...this.state, backend: 'airplay', state: 'error', ready: false, message: event.message}); + } + } + private wireProcess(): void { const child = this.process; if (!child) { @@ -257,6 +401,12 @@ export class PlayerController { return this.queryMpv(payload).then(() => undefined); } + private sendAirPlayCommand(command: AirPlayWorkerCommand): void { + if (this.backend === 'airplay' && this.process && !this.process.killed) { + this.process.stdin.write(serializeWorkerMessage(command)); + } + } + private queryMpv(payload: unknown): Promise { if (!this.ipcPath) { return Promise.resolve(null); @@ -462,6 +612,20 @@ export function createMpvIpcPath( return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`); } +function airPlayWorkerPath(): string { + const currentPath = fileURLToPath(import.meta.url); + const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js'; + return join(dirname(currentPath), `airplay-worker${extension}`); +} + +function airPlayWorkerArgs(workerPath: string, encodedStart: string): string[] { + if (workerPath.endsWith('.ts')) { + return ['--import', 'tsx', workerPath, encodedStart]; + } + + return [workerPath, encodedStart]; +} + function isWindowsNamedPipePath(path: string): boolean { return path.startsWith('\\\\.\\pipe\\'); } diff --git a/src/storage/store.test.ts b/src/storage/store.test.ts index e402d95..abbb98e 100644 --- a/src/storage/store.test.ts +++ b/src/storage/store.test.ts @@ -36,7 +36,13 @@ describe('JsonLibraryStore', () => { store.finishActiveListeningSession(new Date('2026-05-24T12:05:00.000Z')); store.toggleFavorite(station); store.addImported([{...station, id: 'custom-1', provider: 'playlist', streamUrl: 'https://example.com/live'}]); - store.updateSettings({theme: 'amber', receiverStyle: 'mesh', mediaKeys: {previous: ['prev'], playPause: ['pause'], next: ['next']}}); + store.updateSettings({ + theme: 'amber', + receiverStyle: 'mesh', + preferredBackend: 'airplay', + preferredAirPlayDevice: '5CAAFD0046D4@Office', + mediaKeys: {previous: ['prev'], playPause: ['pause'], next: ['next']} + }); const reloaded = new JsonLibraryStore(file).snapshot(); expect(reloaded.recent[0]?.station.name).toBe('Test FM'); @@ -46,6 +52,8 @@ describe('JsonLibraryStore', () => { expect(reloaded.settings.theme).toBe('amber'); expect(reloaded.settings.receiverStyle).toBe('mesh'); expect(reloaded.settings.receiverStyleVersion).toBe(2); + expect(reloaded.settings.preferredBackend).toBe('airplay'); + expect(reloaded.settings.preferredAirPlayDevice).toBe('5CAAFD0046D4@Office'); expect(reloaded.settings.mediaKeys.next).toEqual(['next']); }); @@ -96,6 +104,34 @@ describe('JsonLibraryStore', () => { } ); + it('defaults missing preferred AirPlay device to undefined', () => { + const root = mkdtempSync(join(tmpdir(), 'radiocli-')); + roots.push(root); + const file = join(root, 'library.json'); + writeFileSync( + file, + JSON.stringify({ + recent: [], + favorites: [], + imported: [], + activity: {sessions: []}, + settings: { + theme: 'ruby', + receiverStyle: 'mesh', + volume: 70, + enableRadioGarden: false, + enableNearbyLocation: false, + preferredBackend: 'auto', + tuneTimeoutSeconds: 12, + skipBrokenStreams: true + } + }), + 'utf8' + ); + + expect(new JsonLibraryStore(file).snapshot().settings.preferredAirPlayDevice).toBeUndefined(); + }); + it('persists every receiver style exposed by the UI cycle', () => { const root = mkdtempSync(join(tmpdir(), 'radiocli-')); roots.push(root); diff --git a/src/storage/store.ts b/src/storage/store.ts index 0ac65b3..6b73d11 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -152,7 +152,8 @@ const settingsSchema: z.ZodType = z.object({ volume: z.number().min(0).max(100).default(70), enableRadioGarden: z.boolean().default(false), enableNearbyLocation: z.boolean().default(false), - preferredBackend: z.enum(['auto', 'mpv', 'ffplay']).default('auto'), + preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'airplay']).default('auto'), + preferredAirPlayDevice: z.string().min(1).optional(), tuneTimeoutSeconds: z.number().min(3).max(45).default(12), skipBrokenStreams: z.boolean().default(true), mediaKeys: z diff --git a/src/types.ts b/src/types.ts index 48a440f..69bc59b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,6 +135,16 @@ export type MediaKeyBindings = { next: string[]; }; +export type AirPlayDevice = { + id: string; + name: string; + host: string; + port: number; + txt: string[]; + requiresPassword: boolean; + airplay2: boolean; +}; + export type AppSettings = { theme: ThemeName; receiverStyle: ReceiverStyle; @@ -142,7 +152,8 @@ export type AppSettings = { volume: number; enableRadioGarden: boolean; enableNearbyLocation: boolean; - preferredBackend: 'auto' | 'mpv' | 'ffplay'; + preferredBackend: 'auto' | 'mpv' | 'ffplay' | 'airplay'; + preferredAirPlayDevice?: string; tuneTimeoutSeconds: number; skipBrokenStreams: boolean; mediaKeys: MediaKeyBindings; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2ffb597..9b9419a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -4,7 +4,7 @@ import {ProviderManager} from '../providers/provider-manager.js'; import {PlayerController} from '../player/player-controller.js'; import {playbackBackendInstallHint} from '../player/backend-install.js'; import {JsonLibraryStore, stationKey} from '../storage/store.js'; -import {receiverStyleNames, type AppSettings, type Country, type IcyNowPlaying, type LibraryState, type LocationGuess, type PlaybackState, type Screen, type Station} from '../types.js'; +import {receiverStyleNames, type AirPlayDevice, type AppSettings, type Country, type IcyNowPlaying, type LibraryState, type LocationGuess, type PlaybackState, type Screen, type Station} from '../types.js'; import {appBackground, nextReceiverStyle, nextTheme, panelBackground, textDim, themeAccent} from './theme.js'; import {homeItems, settingsItems} from './screen-items.js'; import {AppContent} from './AppContent.js'; @@ -29,6 +29,8 @@ import { initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, + nextAirPlayDeviceId, + nextPlaybackBackend, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, @@ -71,6 +73,7 @@ export function App({store: providedStore, providers: providedProviders}: AppPro const player = useMemo(() => new PlayerController(() => settingsRef.current), []); const [playback, setPlayback] = useState(() => player.getState()); const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends()); + const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices()); const [screen, setScreen] = useState('home'); const [selected, setSelected] = useState(0); const [message, setMessage] = useState(null); @@ -285,6 +288,7 @@ export function App({store: providedStore, providers: providedProviders}: AppPro useEffect(() => { const backends = player.refreshDetectedBackends(); setAvailableBackends(backends); + void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([])); if (backends.length === 0) { setMessage(`No playback backend found. ${playbackBackendInstallHint()}`); } @@ -685,12 +689,25 @@ export function App({store: providedStore, providers: providedProviders}: AppPro }, [updateSettings]); const cyclePlaybackBackend = useCallback(() => { - const current = settingsRef.current.preferredBackend; - const preferredBackend = current === 'auto' ? 'mpv' : current === 'mpv' ? 'ffplay' : 'auto'; + const preferredBackend = nextPlaybackBackend(settingsRef.current.preferredBackend); updateSettings({preferredBackend}); setMessage(`Playback backend: ${preferredBackend}`); }, [updateSettings]); + const cycleAirPlayTarget = useCallback(() => { + void player.refreshAirPlayDevices().then(devices => { + setAvailableAirPlayDevices(devices); + const preferredAirPlayDevice = nextAirPlayDeviceId(settingsRef.current.preferredAirPlayDevice, devices); + updateSettings({preferredAirPlayDevice}); + const selectedAirPlayDevice = devices.find(device => device.id === preferredAirPlayDevice); + setMessage(`AirPlay target: ${selectedAirPlayDevice?.name ?? 'auto'}`); + }).catch(() => { + setAvailableAirPlayDevices([]); + updateSettings({preferredAirPlayDevice: undefined}); + setMessage('No AirPlay receivers found.'); + }); + }, [player, updateSettings]); + const toggleSkipBrokenStreams = useCallback(() => { const skipBrokenStreams = !settingsRef.current.skipBrokenStreams; updateSettings({skipBrokenStreams}); @@ -812,6 +829,7 @@ export function App({store: providedStore, providers: providedProviders}: AppPro commandMode, commandText, currentItemCount, + cycleAirPlayTarget, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, @@ -900,6 +918,7 @@ export function App({store: providedStore, providers: providedProviders}: AppPro ) : null} { expect(nextSleepTimerMinutes(60)).toBeNull(); }); + it('cycles playback backend through AirPlay', () => { + expect(nextPlaybackBackend('auto')).toBe('mpv'); + expect(nextPlaybackBackend('mpv')).toBe('ffplay'); + expect(nextPlaybackBackend('ffplay')).toBe('airplay'); + expect(nextPlaybackBackend('airplay')).toBe('auto'); + }); + + it('cycles AirPlay targets through discovered receivers', () => { + const devices = [ + {id: 'living@Living Room', name: 'Living Room', host: 'living.local', port: 7000, txt: [], requiresPassword: true, airplay2: true}, + {id: 'office@Office', name: 'Office', host: 'office.local', port: 7000, txt: [], requiresPassword: false, airplay2: true} + ]; + + expect(nextAirPlayDeviceId(undefined, devices)).toBe('living@Living Room'); + expect(nextAirPlayDeviceId('living@Living Room', devices)).toBe('office@Office'); + expect(nextAirPlayDeviceId('office@Office', devices)).toBeUndefined(); + expect(nextAirPlayDeviceId('missing', devices)).toBeUndefined(); + expect(nextAirPlayDeviceId(undefined, [])).toBeUndefined(); + }); + it('moves the explore cursor around the globe and wraps longitude', () => { expect(formatExploreCursor(defaultExploreCursor)).toBe('48.9N, 2.4E'); expect(moveExploreCursor({latitude: 83, longitude: 176}, 'up')).toEqual({latitude: 84, longitude: 176}); diff --git a/src/ui/app-state.ts b/src/ui/app-state.ts index 6f2baa1..bb376b1 100644 --- a/src/ui/app-state.ts +++ b/src/ui/app-state.ts @@ -1,4 +1,4 @@ -import type {MediaKeyBindings, PlaybackState, Screen, Station} from '../types.js'; +import type {AirPlayDevice, AppSettings, MediaKeyBindings, PlaybackState, Screen, Station} from '../types.js'; import type {TopTab} from './components/TopTabs.js'; export type StationContext = { @@ -51,6 +51,7 @@ const emptyMediaKeyBindings: MediaKeyBindings = { const mediaTransportActions = ['previous', 'playPause', 'next'] as const; const sleepTimerOptions: SleepTimerMinutes[] = [null, 15, 30, 60]; +const playbackBackendOptions: AppSettings['preferredBackend'][] = ['auto', 'mpv', 'ffplay', 'airplay']; export const defaultExploreCursor: ExploreCursor = { latitude: 48.8566, longitude: 2.3522 @@ -147,6 +148,29 @@ export function nextSleepTimerMinutes(currentMinutes: number | null): SleepTimer return sleepTimerOptions[nextIndex] ?? null; } +export function nextPlaybackBackend(current: AppSettings['preferredBackend']): AppSettings['preferredBackend'] { + const index = playbackBackendOptions.indexOf(current); + return playbackBackendOptions[(index + 1) % playbackBackendOptions.length] ?? 'auto'; +} + +export function nextAirPlayDeviceId(current: string | undefined, devices: AirPlayDevice[]): string | undefined { + if (devices.length === 0) { + return undefined; + } + + const ids = devices.map(device => device.id); + if (!current) { + return ids[0]; + } + + const index = ids.indexOf(current); + if (index === -1 || index === ids.length - 1) { + return undefined; + } + + return ids[index + 1]; +} + export function moveExploreCursor(cursor: ExploreCursor, direction: ExploreMoveDirection, fast = false): ExploreCursor { const latitudeStep = fast ? 6 : 1; const longitudeStep = fast ? 12 : 2; diff --git a/src/ui/page-footer.ts b/src/ui/page-footer.ts index ba978b4..fa5535f 100644 --- a/src/ui/page-footer.ts +++ b/src/ui/page-footer.ts @@ -67,7 +67,7 @@ export function pageFooterText({ } if (screen === 'settings') { - return 'Enter change selected · g Radio Garden · l location · x skip · o backend · r health · b home'; + return 'Enter change selected · g Radio Garden · l location · x skip · o backend · a AirPlay · r health · b home'; } if (screen === 'stats') { diff --git a/src/ui/screen-items.ts b/src/ui/screen-items.ts index 5d93857..7e341b1 100644 --- a/src/ui/screen-items.ts +++ b/src/ui/screen-items.ts @@ -15,6 +15,7 @@ export const settingsItems = [ 'Toggle Radio Garden experimental adapter', 'Toggle nearby location lookup', 'Cycle playback backend', + 'Cycle AirPlay target', 'Volume up', 'Volume down', 'Mute or unmute', diff --git a/src/ui/screens/SettingsScreen.tsx b/src/ui/screens/SettingsScreen.tsx index 6818381..41ef5b4 100644 --- a/src/ui/screens/SettingsScreen.tsx +++ b/src/ui/screens/SettingsScreen.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {Box, Text} from 'ink'; -import type {AppSettings, PlaybackDiagnostics, PlaybackState, ThemeName} from '../../types.js'; +import type {AirPlayDevice, AppSettings, PlaybackDiagnostics, PlaybackState, ThemeName} from '../../types.js'; import {Menu, Pointer} from '../components/Menu.js'; import {ScreenHeader} from '../components/ScreenHeader.js'; import {truncate} from '../format.js'; @@ -13,6 +13,7 @@ type SettingsScreenProps = { storePath: string; playback: PlaybackState; backends: string[]; + airPlayDevices: AirPlayDevice[]; providerHealth: Record; theme: ThemeName; diagnostics: PlaybackDiagnostics; @@ -25,6 +26,7 @@ export function SettingsScreen({ storePath, playback, backends, + airPlayDevices, providerHealth, theme, diagnostics, @@ -48,7 +50,7 @@ export function SettingsScreen({ selected={selected} keyFor={item => item} render={(item, _index, active) => { - const value = settingValue(item, settings, diagnostics, backends); + const value = settingValue(item, settings, diagnostics, backends, airPlayDevices); return ( @@ -88,7 +90,8 @@ function settingValue( item: string, settings: AppSettings, diagnostics: PlaybackDiagnostics, - backends: string[] + backends: string[], + airPlayDevices: AirPlayDevice[] ): string | undefined { switch (item) { case 'Cycle display color': @@ -101,6 +104,10 @@ function settingValue( return settings.enableNearbyLocation ? 'on' : 'off'; case 'Cycle playback backend': return `${settings.preferredBackend} · available ${backends.length ? backends.join(', ') : 'none'}`; + case 'Cycle AirPlay target': { + const device = airPlayDevices.find(candidate => candidate.id === settings.preferredAirPlayDevice); + return `${device?.name ?? settings.preferredAirPlayDevice ?? 'auto'} · ${airPlayDevices.length || 'no'} found`; + } case 'Mute or unmute': return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`; case 'Toggle skip broken streams': diff --git a/src/ui/use-app-input.ts b/src/ui/use-app-input.ts index af6e3b1..0678172 100644 --- a/src/ui/use-app-input.ts +++ b/src/ui/use-app-input.ts @@ -33,6 +33,7 @@ type AppInputOptions = { commandMode: boolean; commandText: string; currentItemCount: (screen: Screen) => number; + cycleAirPlayTarget: () => void; cycleDisplayColor: () => void; cyclePlaybackBackend: () => void; cycleReceiverStyle: () => void; @@ -88,6 +89,7 @@ export function useAppInput({ commandMode, commandText, currentItemCount, + cycleAirPlayTarget, cycleDisplayColor, cyclePlaybackBackend, cycleReceiverStyle, @@ -368,6 +370,11 @@ export function useAppInput({ return; } + if (input === 'a' && screen === 'settings') { + cycleAirPlayTarget(); + return; + } + if (input === 'g') { toggleRadioGarden(); return; @@ -513,6 +520,8 @@ export function useAppInput({ toggleNearbyLocation(); } else if (item === 'Cycle playback backend') { cyclePlaybackBackend(); + } else if (item === 'Cycle AirPlay target') { + cycleAirPlayTarget(); } else if (item === 'Volume up') { adjustVolume(5); } else if (item === 'Volume down') { diff --git a/src/ui/use-command-executor.ts b/src/ui/use-command-executor.ts index 2ec2896..740fb25 100644 --- a/src/ui/use-command-executor.ts +++ b/src/ui/use-command-executor.ts @@ -181,6 +181,16 @@ export function useCommandExecutor({ return; } + if (name === 'airplay-code' || name === 'airplay-passcode') { + if (!value.trim()) { + setMessage('Usage: :airplay-code '); + return; + } + + player.submitAirPlayPasscode(value.trim()); + return; + } + if (name === 'sleep') { const minutes = Number(value); setSleepUntil(Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : null);