Skip to main content

snapcast_client/
lib.rs

1#![deny(unsafe_code)]
2#![warn(clippy::redundant_closure)]
3#![warn(clippy::implicit_clone)]
4#![warn(clippy::uninlined_format_args)]
5#![warn(missing_docs)]
6
7//! Snapcast client library — embeddable synchronized multiroom audio client.
8//!
9//! See also: [`snapcast-server`](https://docs.rs/snapcast-server) for the server library.
10//!
11//! # Example
12//! ```no_run
13//! use snapcast_client::{SnapClient, ClientConfig, ClientEvent, ClientCommand};
14//!
15//! # async fn example() -> anyhow::Result<()> {
16//! let config = ClientConfig::default();
17//! let (mut client, mut events, mut audio_rx) = SnapClient::new(config);
18//! let cmd = client.command_sender();
19//!
20//! // React to events in a separate task
21//! tokio::spawn(async move {
22//!     while let Some(event) = events.recv().await {
23//!         match event {
24//!             ClientEvent::VolumeChanged { volume, muted } => {
25//!                 println!("Volume: {volume}, muted: {muted}");
26//!             }
27//!             _ => {}
28//!         }
29//!     }
30//! });
31//!
32//! // Stop on Ctrl-C
33//! let stop = cmd.clone();
34//! tokio::spawn(async move {
35//!     tokio::signal::ctrl_c().await.ok();
36//!     stop.send(ClientCommand::Stop).await.ok();
37//! });
38//!
39//! client.run().await?;
40//! # Ok(())
41//! # }
42//! ```
43
44pub mod config;
45pub mod connection;
46pub(crate) mod controller;
47#[cfg(feature = "encryption")]
48pub(crate) mod crypto;
49pub mod decoder;
50pub(crate) mod double_buffer;
51pub mod stream;
52pub mod time_provider;
53
54#[cfg(feature = "mdns")]
55pub mod discovery;
56
57#[cfg(feature = "resampler")]
58pub mod resampler;
59
60use tokio::sync::mpsc;
61
62const EVENT_CHANNEL_SIZE: usize = 256;
63const COMMAND_CHANNEL_SIZE: usize = 64;
64const AUDIO_CHANNEL_SIZE: usize = 256;
65
66// Re-export proto types that embedders need
67#[cfg(feature = "custom-protocol")]
68pub use snapcast_proto::CustomMessage;
69pub use snapcast_proto::SampleFormat;
70pub use snapcast_proto::{DEFAULT_STREAM_PORT, PROTOCOL_VERSION};
71
72/// Interleaved f32 audio frame produced by the client library.
73#[derive(Debug, Clone)]
74pub struct AudioFrame {
75    /// Interleaved f32 samples (channel-interleaved).
76    pub samples: Vec<f32>,
77    /// Sample rate in Hz.
78    pub sample_rate: u32,
79    /// Number of channels.
80    pub channels: u16,
81    /// Server timestamp in microseconds (for sync).
82    pub timestamp_usec: i64,
83}
84
85/// Events emitted by the client to the consumer.
86#[derive(Debug, Clone)]
87pub enum ClientEvent {
88    /// Connected to server.
89    Connected {
90        /// Server hostname or IP.
91        host: String,
92        /// Server port.
93        port: u16,
94    },
95    /// Disconnected from server.
96    Disconnected {
97        /// Reason for disconnection.
98        reason: String,
99    },
100    /// Audio stream started with the given format.
101    StreamStarted {
102        /// Codec name (e.g. "flac", "opus").
103        codec: String,
104        /// PCM sample format.
105        format: SampleFormat,
106    },
107    /// Server settings received or updated.
108    ServerSettings {
109        /// Playout buffer in milliseconds.
110        buffer_ms: i32,
111        /// Additional latency in milliseconds.
112        latency: i32,
113        /// Volume (0–100).
114        volume: u16,
115        /// Mute state.
116        muted: bool,
117    },
118    /// Volume changed (from server or local).
119    VolumeChanged {
120        /// Volume (0–100).
121        volume: u16,
122        /// Mute state.
123        muted: bool,
124    },
125    /// Time sync completed.
126    TimeSyncComplete {
127        /// Clock difference to server in milliseconds.
128        diff_ms: f64,
129    },
130    #[cfg(feature = "custom-protocol")]
131    /// Custom message received from server.
132    CustomMessage(snapcast_proto::CustomMessage),
133}
134
135/// Commands the consumer sends to the client.
136#[derive(Debug, Clone)]
137pub enum ClientCommand {
138    /// Set volume (0–100) and mute state.
139    SetVolume {
140        /// Volume (0–100).
141        volume: u16,
142        /// Mute state.
143        muted: bool,
144    },
145    /// Send a custom message to the server.
146    #[cfg(feature = "custom-protocol")]
147    SendCustom(snapcast_proto::CustomMessage),
148    /// Stop the client gracefully.
149    Stop,
150}
151
152/// Configuration for the embeddable client.
153#[derive(Debug, Clone)]
154pub struct ClientConfig {
155    /// Connection scheme: "tcp", "ws", or "wss". Default: "tcp".
156    pub scheme: String,
157    /// Server hostname or IP (empty = mDNS discovery).
158    pub host: String,
159    /// Server port. Default: 1704.
160    pub port: u16,
161    /// Optional authentication for Hello handshake.
162    pub auth: Option<crate::config::Auth>,
163    /// Server CA certificate for TLS verification.
164    #[cfg(feature = "tls")]
165    pub server_certificate: Option<std::path::PathBuf>,
166    /// Client certificate (PEM).
167    #[cfg(feature = "tls")]
168    pub certificate: Option<std::path::PathBuf>,
169    /// Client private key (PEM).
170    #[cfg(feature = "tls")]
171    pub certificate_key: Option<std::path::PathBuf>,
172    /// Password for encrypted private key.
173    #[cfg(feature = "tls")]
174    pub key_password: Option<String>,
175    /// Instance id (for multiple clients on one host).
176    pub instance: u32,
177    /// Unique host identifier (default: MAC address).
178    pub host_id: String,
179    /// Additional latency in milliseconds (subtracted from buffer).
180    pub latency: i32,
181    /// mDNS service type. Default: "_snapcast._tcp.local.".
182    pub mdns_service_type: String,
183    /// Client name sent in Hello. Default: "Snapclient".
184    pub client_name: String,
185    /// Pre-shared key for f32lz4 decryption. `None` = auto-detect from env SNAPCAST_PSK.
186    #[cfg(feature = "encryption")]
187    pub encryption_psk: Option<String>,
188}
189
190impl Default for ClientConfig {
191    fn default() -> Self {
192        Self {
193            scheme: "tcp".into(),
194            host: String::new(),
195            port: snapcast_proto::DEFAULT_STREAM_PORT,
196            auth: None,
197            #[cfg(feature = "tls")]
198            server_certificate: None,
199            #[cfg(feature = "tls")]
200            certificate: None,
201            #[cfg(feature = "tls")]
202            certificate_key: None,
203            #[cfg(feature = "tls")]
204            key_password: None,
205            instance: 1,
206            host_id: String::new(),
207            latency: 0,
208            mdns_service_type: "_snapcast._tcp.local.".into(),
209            client_name: "Snapclient".into(),
210            #[cfg(feature = "encryption")]
211            encryption_psk: None,
212        }
213    }
214}
215
216/// The embeddable Snapcast client.
217pub struct SnapClient {
218    config: ClientConfig,
219    event_tx: mpsc::Sender<ClientEvent>,
220    command_tx: mpsc::Sender<ClientCommand>,
221    command_rx: Option<mpsc::Receiver<ClientCommand>>,
222    /// Shared time provider — accessible by the binary for audio output.
223    pub time_provider: std::sync::Arc<std::sync::Mutex<time_provider::TimeProvider>>,
224    /// Shared stream — accessible by the binary for audio output.
225    pub stream: std::sync::Arc<std::sync::Mutex<stream::Stream>>,
226}
227
228impl SnapClient {
229    /// Create a new client. Returns the client, event receiver, and audio output receiver.
230    pub fn new(
231        config: ClientConfig,
232    ) -> (
233        Self,
234        mpsc::Receiver<ClientEvent>,
235        mpsc::Receiver<AudioFrame>,
236    ) {
237        let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_SIZE);
238        let (command_tx, command_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
239        let (_audio_tx, audio_rx) = mpsc::channel(AUDIO_CHANNEL_SIZE);
240        let time_provider =
241            std::sync::Arc::new(std::sync::Mutex::new(time_provider::TimeProvider::new()));
242        let stream = std::sync::Arc::new(std::sync::Mutex::new(stream::Stream::new(
243            SampleFormat::default(),
244        )));
245        let client = Self {
246            config,
247            event_tx,
248            command_tx,
249            command_rx: Some(command_rx),
250            time_provider,
251            stream,
252        };
253        (client, event_rx, audio_rx)
254    }
255
256    /// Get a cloneable command sender.
257    pub fn command_sender(&self) -> mpsc::Sender<ClientCommand> {
258        self.command_tx.clone()
259    }
260
261    /// Run the client. Blocks until stopped or a fatal error occurs.
262    pub async fn run(&mut self) -> anyhow::Result<()> {
263        let command_rx = self
264            .command_rx
265            .take()
266            .ok_or_else(|| anyhow::anyhow!("run() already called"))?;
267
268        let mut ctrl = controller::Controller::new(
269            self.config.clone(),
270            self.event_tx.clone(),
271            command_rx,
272            std::sync::Arc::clone(&self.time_provider),
273            std::sync::Arc::clone(&self.stream),
274        );
275        ctrl.run().await
276    }
277}