Skip to main content

grafton_ndi/
receiver.rs

1//! NDI receiving functionality for video, audio, and metadata.
2//!
3//! # Monitoring Tally & Connection Count
4//!
5//! The receiver can monitor status changes including tally state and connection count:
6//!
7//! ```ignore
8//! # use grafton_ndi::{NDI, ReceiverOptions, ReceiverBandwidth, Source};
9//! # use std::time::Duration;
10//! # fn main() -> Result<(), grafton_ndi::Error> {
11//! # let ndi = NDI::new()?;
12//! // In real usage, you'd get the source from Finder::find_sources()
13//! // let source = /* obtained from Finder */;
14//! let options = ReceiverOptions::builder(source)
15//!     .bandwidth(ReceiverBandwidth::MetadataOnly)
16//!     .build();
17//! let receiver = Receiver::new(&ndi, &options)?;
18//!
19//! // Poll for status changes
20//! if let Some(status) = receiver.poll_status_change(Duration::from_millis(1000))? {
21//!     if let Some(tally) = status.tally {
22//!         println!("Tally: program={program}, preview={preview}",
23//!                  program = tally.on_program, preview = tally.on_preview);
24//!     }
25//!     if let Some(connections) = status.connections {
26//!         println!("Active connections: {connections}");
27//!     }
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use std::{
34    ffi::CString,
35    marker::PhantomData,
36    ptr,
37    sync::{PoisonError, RwLock, RwLockReadGuard},
38    time::{Duration, Instant},
39};
40
41use crate::{
42    capture::{capture_raw, AudioKind, CaptureKind, CaptureResult, MetadataKind, VideoKind},
43    finder::{RawSource, Source},
44    ndi_lib::*,
45    to_ms_checked, Error, Result, NDI,
46};
47
48/// Retry policy configuration for frame capture operations.
49///
50/// This struct encapsulates the timing parameters for the retry loop used by
51/// the reliable [`Capture::capture`] verb across all frame kinds.
52struct RetryPolicy {
53    /// Timeout per individual capture attempt.
54    poll_interval: Duration,
55    /// Sleep duration between retry attempts to avoid busy-waiting.
56    sleep_between: Duration,
57}
58
59impl Default for RetryPolicy {
60    fn default() -> Self {
61        Self {
62            poll_interval: Duration::from_millis(100),
63            sleep_between: Duration::from_millis(10),
64        }
65    }
66}
67
68/// Generic retry helper for frame capture operations.
69///
70/// This function encapsulates the retry logic that handles NDI SDK synchronization
71/// behavior during initial connection. The first few capture calls may return
72/// immediately while the stream synchronizes.
73///
74/// # Parameters
75///
76/// - `timeout`: Total time allowed for the operation to succeed.
77/// - `policy`: Retry timing configuration.
78/// - `capture_fn`: A closure that attempts to capture a frame with a given timeout.
79///
80/// # Returns
81///
82/// - `Ok(T)`: The captured frame on success.
83/// - `Err(Error::FrameTimeout)`: If no frame is captured within the total timeout.
84fn retry_capture<T, F>(timeout: Duration, policy: &RetryPolicy, capture_fn: F) -> Result<T>
85where
86    F: FnMut(Duration) -> Result<Option<T>>,
87{
88    let start_time = Instant::now();
89    retry_capture_with_clock(
90        timeout,
91        policy,
92        capture_fn,
93        || start_time.elapsed(),
94        std::thread::sleep,
95    )
96}
97
98fn retry_capture_with_clock<T, F, E, S>(
99    timeout: Duration,
100    policy: &RetryPolicy,
101    mut capture_fn: F,
102    mut elapsed: E,
103    mut sleep: S,
104) -> Result<T>
105where
106    F: FnMut(Duration) -> Result<Option<T>>,
107    E: FnMut() -> Duration,
108    S: FnMut(Duration),
109{
110    to_ms_checked(timeout)?;
111
112    let mut attempts = 0;
113
114    if timeout.is_zero() {
115        attempts += 1;
116        return match capture_fn(Duration::ZERO)? {
117            Some(frame) => Ok(frame),
118            None => Err(Error::FrameTimeout {
119                attempts,
120                elapsed: elapsed(),
121            }),
122        };
123    }
124
125    loop {
126        let elapsed_before_attempt = elapsed();
127        let Some(remaining) = timeout.checked_sub(elapsed_before_attempt) else {
128            return Err(Error::FrameTimeout {
129                attempts,
130                elapsed: elapsed_before_attempt,
131            });
132        };
133
134        if remaining.is_zero() {
135            return Err(Error::FrameTimeout {
136                attempts,
137                elapsed: elapsed_before_attempt,
138            });
139        }
140
141        let poll_timeout = policy.poll_interval.min(remaining);
142        attempts += 1;
143
144        match capture_fn(poll_timeout)? {
145            Some(frame) => return Ok(frame),
146            None => {
147                let elapsed_after_attempt = elapsed();
148                let Some(remaining) = timeout.checked_sub(elapsed_after_attempt) else {
149                    return Err(Error::FrameTimeout {
150                        attempts,
151                        elapsed: elapsed_after_attempt,
152                    });
153                };
154
155                if remaining.is_zero() {
156                    return Err(Error::FrameTimeout {
157                        attempts,
158                        elapsed: elapsed_after_attempt,
159                    });
160                }
161
162                let sleep_duration = policy.sleep_between.min(remaining);
163                if !sleep_duration.is_zero() {
164                    sleep(sleep_duration);
165                }
166            }
167        }
168    }
169}
170
171macro_rules! ptz_command {
172    ($self:expr, $func:ident, $err_msg:expr) => {
173        if unsafe { $func($self.instance) } {
174            Ok(())
175        } else {
176            Err(Error::PtzCommandFailed($err_msg.into()))
177        }
178    };
179    ($self:expr, $func:ident, $param:expr, $err_msg:expr) => {
180        if unsafe { $func($self.instance, $param) } {
181            Ok(())
182        } else {
183            Err(Error::PtzCommandFailed($err_msg))
184        }
185    };
186    ($self:expr, $func:ident, $param1:expr, $param2:expr, $err_msg:expr) => {
187        if unsafe { $func($self.instance, $param1, $param2) } {
188            Ok(())
189        } else {
190            Err(Error::PtzCommandFailed($err_msg))
191        }
192    };
193    ($self:expr, $func:ident, $param1:expr, $param2:expr, $param3:expr, $err_msg:expr) => {
194        if unsafe { $func($self.instance, $param1, $param2, $param3) } {
195            Ok(())
196        } else {
197            Err(Error::PtzCommandFailed($err_msg))
198        }
199    };
200}
201
202/// Operational color formats for received video frames.
203///
204/// These values are receiver configuration modes that the NDI SDK can use for
205/// normal receive operation. SDK `Max` sentinel values are intentionally not
206/// exposed in safe Rust because they are placeholders, not valid receiver
207/// modes.
208///
209/// This enum is marked `#[non_exhaustive]` so future SDK receiver modes can be
210/// added without another avoidable public enum break. Downstream `match`
211/// expressions should include a wildcard arm.
212///
213/// Choose an explicit operational value such as [`ReceiverColorFormat::Fastest`],
214/// [`ReceiverColorFormat::Best`], [`ReceiverColorFormat::RGBX_RGBA`], or
215/// [`ReceiverColorFormat::BGRX_BGRA`] for receiver configuration.
216#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
217#[non_exhaustive]
218pub enum ReceiverColorFormat {
219    #[default]
220    BGRX_BGRA,
221    UYVY_BGRA,
222    RGBX_RGBA,
223    UYVY_RGBA,
224    Fastest,
225    Best,
226}
227
228impl From<ReceiverColorFormat> for NDIlib_recv_color_format_e {
229    fn from(format: ReceiverColorFormat) -> Self {
230        match format {
231            ReceiverColorFormat::BGRX_BGRA => {
232                NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA
233            }
234            ReceiverColorFormat::UYVY_BGRA => {
235                NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_BGRA
236            }
237            ReceiverColorFormat::RGBX_RGBA => {
238                NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA
239            }
240            ReceiverColorFormat::UYVY_RGBA => {
241                NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_RGBA
242            }
243            ReceiverColorFormat::Fastest => {
244                NDIlib_recv_color_format_e_NDIlib_recv_color_format_fastest
245            }
246            ReceiverColorFormat::Best => NDIlib_recv_color_format_e_NDIlib_recv_color_format_best,
247        }
248    }
249}
250
251/// Operational bandwidth modes for receivers.
252///
253/// These values are receiver configuration modes that the NDI SDK can use for
254/// normal receive operation. SDK `Max` sentinel values are intentionally not
255/// exposed in safe Rust because they are placeholders, not valid receiver
256/// modes.
257///
258/// This enum is marked `#[non_exhaustive]` so future SDK receiver modes can be
259/// added without another avoidable public enum break. Downstream `match`
260/// expressions should include a wildcard arm.
261///
262/// Choose an explicit operational value such as [`ReceiverBandwidth::Highest`],
263/// [`ReceiverBandwidth::Lowest`], [`ReceiverBandwidth::AudioOnly`], or
264/// [`ReceiverBandwidth::MetadataOnly`] for receiver configuration.
265#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
266#[non_exhaustive]
267pub enum ReceiverBandwidth {
268    MetadataOnly,
269    AudioOnly,
270    Lowest,
271    #[default]
272    Highest,
273}
274
275impl From<ReceiverBandwidth> for NDIlib_recv_bandwidth_e {
276    fn from(bandwidth: ReceiverBandwidth) -> Self {
277        match bandwidth {
278            ReceiverBandwidth::MetadataOnly => {
279                NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only
280            }
281            ReceiverBandwidth::AudioOnly => {
282                NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_audio_only
283            }
284            ReceiverBandwidth::Lowest => NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest,
285            ReceiverBandwidth::Highest => NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
286        }
287    }
288}
289
290#[derive(Debug, Default, Clone)]
291pub struct ReceiverOptions {
292    pub source_to_connect_to: Source,
293    pub color_format: ReceiverColorFormat,
294    pub bandwidth: ReceiverBandwidth,
295    pub allow_video_fields: bool,
296    pub ndi_recv_name: Option<String>,
297}
298
299#[repr(C)]
300pub(crate) struct RawRecvCreateV3 {
301    _source: RawSource,
302    _name: Option<CString>,
303    pub raw: NDIlib_recv_create_v3_t,
304}
305
306impl ReceiverOptions {
307    /// Convert to raw format for FFI use
308    ///
309    /// # Safety
310    ///
311    /// The returned RawRecvCreateV3 struct uses #[repr(C)] to guarantee C-compatible layout
312    /// for safe FFI interop with the NDI SDK.
313    pub(crate) fn to_raw(&self) -> Result<RawRecvCreateV3> {
314        let source = self.source_to_connect_to.to_raw()?;
315        let name = self
316            .ndi_recv_name
317            .as_ref()
318            .map(|n| CString::new(n.clone()))
319            .transpose()
320            .map_err(Error::InvalidCString)?;
321
322        let p_ndi_recv_name = name.as_ref().map_or(ptr::null(), |n| n.as_ptr());
323        let source_raw = source.raw;
324
325        Ok(RawRecvCreateV3 {
326            raw: NDIlib_recv_create_v3_t {
327                source_to_connect_to: source_raw,
328                color_format: self.color_format.into(),
329                bandwidth: self.bandwidth.into(),
330                allow_video_fields: self.allow_video_fields,
331                p_ndi_recv_name,
332            },
333            _source: source,
334            _name: name,
335        })
336    }
337
338    /// Create a builder for configuring a receiver
339    pub fn builder(source: Source) -> ReceiverOptionsBuilder {
340        ReceiverOptionsBuilder::new(source)
341    }
342}
343
344/// Builder for configuring a ReceiverOptions with ergonomic method chaining
345#[derive(Debug, Clone)]
346pub struct ReceiverOptionsBuilder {
347    source_to_connect_to: Source,
348    color_format: Option<ReceiverColorFormat>,
349    bandwidth: Option<ReceiverBandwidth>,
350    allow_video_fields: Option<bool>,
351    ndi_recv_name: Option<String>,
352}
353
354impl ReceiverOptionsBuilder {
355    /// Create a new builder with the specified source
356    pub fn new(source: Source) -> Self {
357        Self {
358            source_to_connect_to: source,
359            color_format: None,
360            bandwidth: None,
361            allow_video_fields: None,
362            ndi_recv_name: None,
363        }
364    }
365
366    /// Preset for capturing snapshots (low resolution, RGBA, lowest bandwidth).
367    ///
368    /// This preset is optimized for:
369    /// - Image export and snapshot capture
370    /// - AI/ML processing pipelines
371    /// - Thumbnail generation
372    /// - Low bandwidth environments
373    ///
374    /// Configuration:
375    /// - Color format: `RGBX_RGBA` (compatible with image encoding)
376    /// - Bandwidth: `Lowest` (reduces resolution and bitrate)
377    /// - Video fields: Disabled (progressive frames only)
378    ///
379    /// # Example
380    ///
381    /// ```no_run
382    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
383    /// # use std::time::Duration;
384    /// # fn main() -> Result<(), grafton_ndi::Error> {
385    /// # let ndi = NDI::new()?;
386    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
387    /// # finder.wait_for_sources(Duration::from_secs(1))?;
388    /// # let sources = finder.current_sources()?;
389    /// let options = ReceiverOptionsBuilder::snapshot_preset(sources[0].clone())
390    ///     .name("Snapshot Receiver")
391    ///     .build();
392    /// let receiver = Receiver::new(&ndi, &options)?;
393    ///
394    /// // Capture and encode in one line (requires image-encoding feature)
395    /// #[cfg(feature = "image-encoding")]
396    /// {
397    ///     let frame = receiver.video().capture(Duration::from_secs(5))?;
398    ///     let png_bytes = frame.encode_png()?;
399    ///     std::fs::write("snapshot.png", &png_bytes)?;
400    /// }
401    /// # Ok(())
402    /// # }
403    /// ```
404    pub fn snapshot_preset(source: Source) -> Self {
405        Self::new(source)
406            .color(ReceiverColorFormat::RGBX_RGBA)
407            .bandwidth(ReceiverBandwidth::Lowest)
408            .allow_video_fields(false)
409    }
410
411    /// Preset for high-quality video processing (full resolution, highest bandwidth).
412    ///
413    /// This preset is optimized for:
414    /// - Professional video processing workflows
415    /// - Recording and archival
416    /// - Real-time video analysis requiring full quality
417    /// - Broadcasting and production
418    ///
419    /// Configuration:
420    /// - Color format: `RGBX_RGBA` (uncompressed, compatible with most tools)
421    /// - Bandwidth: `Highest` (full resolution and bitrate)
422    /// - Video fields: Enabled (supports interlaced sources)
423    ///
424    /// # Example
425    ///
426    /// ```no_run
427    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
428    /// # use std::time::Duration;
429    /// # fn main() -> Result<(), grafton_ndi::Error> {
430    /// # let ndi = NDI::new()?;
431    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
432    /// # finder.wait_for_sources(Duration::from_secs(1))?;
433    /// # let sources = finder.current_sources()?;
434    /// let options = ReceiverOptionsBuilder::high_quality_preset(sources[0].clone())
435    ///     .name("High Quality Receiver")
436    ///     .build();
437    /// let receiver = Receiver::new(&ndi, &options)?;
438    ///
439    /// // Capture full quality frames
440    /// let frame = receiver.video().capture(Duration::from_secs(5))?;
441    /// println!("Captured {width}x{height} frame", width = frame.width(), height = frame.height());
442    /// # Ok(())
443    /// # }
444    /// ```
445    pub fn high_quality_preset(source: Source) -> Self {
446        Self::new(source)
447            .color(ReceiverColorFormat::RGBX_RGBA)
448            .bandwidth(ReceiverBandwidth::Highest)
449            .allow_video_fields(true)
450    }
451
452    /// Preset for metadata and tally monitoring only (no video/audio).
453    ///
454    /// This preset is optimized for:
455    /// - Tally light monitoring
456    /// - Connection status tracking
457    /// - PTZ control applications
458    /// - Minimal bandwidth overhead
459    ///
460    /// Configuration:
461    /// - Bandwidth: `MetadataOnly` (no video or audio data)
462    /// - Color format: Default (not used for metadata-only)
463    /// - Video fields: Disabled (not applicable)
464    ///
465    /// # Example
466    ///
467    /// ```no_run
468    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
469    /// # use std::time::Duration;
470    /// # fn main() -> Result<(), grafton_ndi::Error> {
471    /// # let ndi = NDI::new()?;
472    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
473    /// # finder.wait_for_sources(Duration::from_secs(1))?;
474    /// # let sources = finder.current_sources()?;
475    /// let options = ReceiverOptionsBuilder::monitoring_preset(sources[0].clone())
476    ///     .name("Tally Monitor")
477    ///     .build();
478    /// let receiver = Receiver::new(&ndi, &options)?;
479    ///
480    /// // Poll for status changes
481    /// if let Some(status) = receiver.poll_status_change(Duration::from_millis(1000))? {
482    ///     if let Some(tally) = status.tally {
483    ///         println!("Tally: program={program}, preview={preview}",
484    ///                  program = tally.on_program, preview = tally.on_preview);
485    ///     }
486    ///     if let Some(connections) = status.connections {
487    ///         println!("Active connections: {connections}");
488    ///     }
489    /// }
490    /// # Ok(())
491    /// # }
492    /// ```
493    pub fn monitoring_preset(source: Source) -> Self {
494        Self::new(source)
495            .bandwidth(ReceiverBandwidth::MetadataOnly)
496            .allow_video_fields(false)
497    }
498
499    /// Set the color format for received video
500    #[must_use]
501    pub fn color(mut self, fmt: ReceiverColorFormat) -> Self {
502        self.color_format = Some(fmt);
503        self
504    }
505
506    /// Set the bandwidth mode for the receiver
507    #[must_use]
508    pub fn bandwidth(mut self, bw: ReceiverBandwidth) -> Self {
509        self.bandwidth = Some(bw);
510        self
511    }
512
513    /// Configure whether to allow video fields
514    #[must_use]
515    pub fn allow_video_fields(mut self, allow: bool) -> Self {
516        self.allow_video_fields = Some(allow);
517        self
518    }
519
520    /// Set the name for this receiver
521    #[must_use]
522    pub fn name<S: Into<String>>(mut self, name: S) -> Self {
523        self.ndi_recv_name = Some(name.into());
524        self
525    }
526
527    /// Build the receiver options
528    ///
529    /// This method is infallible and simply applies defaults for any unset options.
530    /// To create a `Receiver`, pass the resulting `ReceiverOptions` to `Receiver::new()`.
531    ///
532    /// # Example
533    ///
534    /// ```no_run
535    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, Source};
536    /// # fn main() -> Result<(), grafton_ndi::Error> {
537    /// # let ndi = NDI::new()?;
538    /// # let source = Source::default();
539    /// let options = ReceiverOptions::builder(source).build();
540    /// let receiver = Receiver::new(&ndi, &options)?;
541    /// # Ok(())
542    /// # }
543    /// ```
544    pub fn build(self) -> ReceiverOptions {
545        ReceiverOptions {
546            source_to_connect_to: self.source_to_connect_to,
547            color_format: self.color_format.unwrap_or(ReceiverColorFormat::BGRX_BGRA),
548            bandwidth: self.bandwidth.unwrap_or(ReceiverBandwidth::Highest),
549            allow_video_fields: self.allow_video_fields.unwrap_or(true),
550            ndi_recv_name: self.ndi_recv_name,
551        }
552    }
553}
554
555pub struct Receiver {
556    pub(crate) instance: NDIlib_recv_instance_t,
557    _ndi: NDI,
558    source: Source,
559    /// Serializes connection changes against in-flight captures.
560    ///
561    /// Capture calls take this lock *shared* (so video, audio, and metadata
562    /// captures still run concurrently), while [`Receiver::reconnect`] takes it
563    /// *exclusively*. This guarantees `NDIlib_recv_connect` never overlaps a
564    /// `NDIlib_recv_capture_v3` on the same instance — the one combination the
565    /// SDK does not make safe — without otherwise serializing capture.
566    capture_guard: RwLock<()>,
567}
568
569impl Receiver {
570    pub fn new(ndi: &NDI, create: &ReceiverOptions) -> Result<Self> {
571        let create_raw = create.to_raw()?;
572        let instance = unsafe { NDIlib_recv_create_v3(&create_raw.raw) };
573        if instance.is_null() {
574            Err(Error::InitializationFailed(
575                "Failed to create NDI recv instance".into(),
576            ))
577        } else {
578            Ok(Self {
579                instance,
580                _ndi: ndi.clone(),
581                source: create.source_to_connect_to.clone(),
582                capture_guard: RwLock::new(()),
583            })
584        }
585    }
586
587    /// Acquire the shared capture guard, held for the duration of a single
588    /// `NDIlib_recv_capture_v3` call. Multiple captures proceed concurrently;
589    /// only [`Self::reconnect`] takes the guard exclusively. The guarded data
590    /// is `()`, so a poisoned lock carries no invalid state and is recovered.
591    fn capture_lock(&self) -> RwLockReadGuard<'_, ()> {
592        self.capture_guard
593            .read()
594            .unwrap_or_else(PoisonError::into_inner)
595    }
596
597    pub fn ptz_is_supported(&self) -> bool {
598        unsafe { NDIlib_recv_ptz_is_supported(self.instance) }
599    }
600
601    pub fn ptz_recall_preset(&self, preset: u32, speed: f32) -> Result<()> {
602        ptz_command!(
603            self,
604            NDIlib_recv_ptz_recall_preset,
605            preset as i32,
606            speed,
607            format!("Failed to recall PTZ preset {preset} with speed {speed}")
608        )
609    }
610
611    pub fn ptz_zoom(&self, zoom_value: f32) -> Result<()> {
612        ptz_command!(
613            self,
614            NDIlib_recv_ptz_zoom,
615            zoom_value,
616            format!("Failed to set PTZ zoom to {zoom_value}")
617        )
618    }
619
620    pub fn ptz_zoom_speed(&self, zoom_speed: f32) -> Result<()> {
621        ptz_command!(
622            self,
623            NDIlib_recv_ptz_zoom_speed,
624            zoom_speed,
625            format!("Failed to set PTZ zoom speed to {zoom_speed}")
626        )
627    }
628
629    pub fn ptz_pan_tilt(&self, pan_value: f32, tilt_value: f32) -> Result<()> {
630        ptz_command!(
631            self,
632            NDIlib_recv_ptz_pan_tilt,
633            pan_value,
634            tilt_value,
635            format!("Failed to set PTZ pan/tilt to ({pan_value}, {tilt_value})")
636        )
637    }
638
639    pub fn ptz_pan_tilt_speed(&self, pan_speed: f32, tilt_speed: f32) -> Result<()> {
640        ptz_command!(
641            self,
642            NDIlib_recv_ptz_pan_tilt_speed,
643            pan_speed,
644            tilt_speed,
645            format!("Failed to set PTZ pan/tilt speed to ({pan_speed}, {tilt_speed})")
646        )
647    }
648
649    pub fn ptz_store_preset(&self, preset_no: i32) -> Result<()> {
650        ptz_command!(
651            self,
652            NDIlib_recv_ptz_store_preset,
653            preset_no,
654            format!("Failed to store PTZ preset {preset_no}")
655        )
656    }
657
658    pub fn ptz_auto_focus(&self) -> Result<()> {
659        ptz_command!(
660            self,
661            NDIlib_recv_ptz_auto_focus,
662            "Failed to enable PTZ auto focus"
663        )
664    }
665
666    pub fn ptz_focus(&self, focus_value: f32) -> Result<()> {
667        ptz_command!(
668            self,
669            NDIlib_recv_ptz_focus,
670            focus_value,
671            format!("Failed to set PTZ focus to {focus_value}")
672        )
673    }
674
675    pub fn ptz_focus_speed(&self, focus_speed: f32) -> Result<()> {
676        ptz_command!(
677            self,
678            NDIlib_recv_ptz_focus_speed,
679            focus_speed,
680            format!("Failed to set PTZ focus speed to {focus_speed}")
681        )
682    }
683
684    pub fn ptz_white_balance_auto(&self) -> Result<()> {
685        ptz_command!(
686            self,
687            NDIlib_recv_ptz_white_balance_auto,
688            "Failed to set PTZ auto white balance"
689        )
690    }
691
692    pub fn ptz_white_balance_indoor(&self) -> Result<()> {
693        ptz_command!(
694            self,
695            NDIlib_recv_ptz_white_balance_indoor,
696            "Failed to set PTZ indoor white balance"
697        )
698    }
699
700    pub fn ptz_white_balance_outdoor(&self) -> Result<()> {
701        ptz_command!(
702            self,
703            NDIlib_recv_ptz_white_balance_outdoor,
704            "Failed to set PTZ outdoor white balance"
705        )
706    }
707
708    pub fn ptz_white_balance_oneshot(&self) -> Result<()> {
709        ptz_command!(
710            self,
711            NDIlib_recv_ptz_white_balance_oneshot,
712            "Failed to set PTZ oneshot white balance"
713        )
714    }
715
716    pub fn ptz_white_balance_manual(&self, red: f32, blue: f32) -> Result<()> {
717        ptz_command!(
718            self,
719            NDIlib_recv_ptz_white_balance_manual,
720            red,
721            blue,
722            format!("Failed to set PTZ manual white balance (red: {red}, blue: {blue})")
723        )
724    }
725
726    pub fn ptz_exposure_auto(&self) -> Result<()> {
727        ptz_command!(
728            self,
729            NDIlib_recv_ptz_exposure_auto,
730            "Failed to set PTZ auto exposure"
731        )
732    }
733
734    pub fn ptz_exposure_manual(&self, exposure_level: f32) -> Result<()> {
735        ptz_command!(
736            self,
737            NDIlib_recv_ptz_exposure_manual,
738            exposure_level,
739            format!("Failed to set PTZ manual exposure to {exposure_level}")
740        )
741    }
742
743    pub fn ptz_exposure_manual_v2(&self, iris: f32, gain: f32, shutter_speed: f32) -> Result<()> {
744        ptz_command!(
745            self,
746            NDIlib_recv_ptz_exposure_manual_v2,
747            iris,
748            gain,
749            shutter_speed,
750            format!("Failed to set PTZ manual exposure v2 (iris: {iris}, gain: {gain}, shutter: {shutter_speed})")
751        )
752    }
753
754    /// Capture **video** frames from this receiver.
755    ///
756    /// Returns a [`Capture`] view whose verbs cover the three capture styles:
757    ///
758    /// - [`capture`](Capture::capture) — reliable owned capture that retries
759    ///   across the NDI SDK's initial-sync warm-up.
760    /// - [`try_capture`](Capture::try_capture) — a single owned poll, `Ok(None)`
761    ///   when no frame is ready.
762    /// - [`try_capture_ref`](Capture::try_capture_ref) — a single zero-copy poll
763    ///   borrowing the SDK buffer in place.
764    ///
765    /// Video frames may carry per-row padding (e.g. RGBX/BGRX); see
766    /// [`VideoFrame`](crate::VideoFrame) for how to read them correctly.
767    ///
768    /// # Examples
769    ///
770    /// ```no_run
771    /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
772    /// # use std::time::Duration;
773    /// # fn main() -> Result<(), grafton_ndi::Error> {
774    /// # let ndi = NDI::new()?;
775    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
776    /// # let options = ReceiverOptions::builder(source).build();
777    /// # let receiver = Receiver::new(&ndi, &options)?;
778    /// // Reliable owned capture
779    /// let frame = receiver.video().capture(Duration::from_secs(5))?;
780    /// println!("{}x{}", frame.width(), frame.height());
781    ///
782    /// // Zero-copy borrow
783    /// if let Some(frame) = receiver.video().try_capture_ref(Duration::from_secs(1))? {
784    ///     let pixels = frame.data();
785    ///     println!("{} bytes in place", pixels.len());
786    /// }
787    /// # Ok(())
788    /// # }
789    /// ```
790    #[must_use = "the Capture view does nothing until a capture verb is called"]
791    pub fn video(&self) -> Capture<'_, VideoKind> {
792        Capture::new(self)
793    }
794
795    /// Capture **audio** frames from this receiver.
796    ///
797    /// Returns a [`Capture`] view; see [`video`](Self::video) for the three
798    /// capture verbs it exposes ([`capture`](Capture::capture),
799    /// [`try_capture`](Capture::try_capture),
800    /// [`try_capture_ref`](Capture::try_capture_ref)).
801    ///
802    /// # Examples
803    ///
804    /// ```no_run
805    /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
806    /// # use std::time::Duration;
807    /// # fn main() -> Result<(), grafton_ndi::Error> {
808    /// # let ndi = NDI::new()?;
809    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
810    /// # let options = ReceiverOptions::builder(source).build();
811    /// # let receiver = Receiver::new(&ndi, &options)?;
812    /// if let Some(audio) = receiver.audio().try_capture_ref(Duration::from_secs(1))? {
813    ///     println!("{} channels, {} samples", audio.num_channels(), audio.num_samples());
814    /// }
815    /// # Ok(())
816    /// # }
817    /// ```
818    #[must_use = "the Capture view does nothing until a capture verb is called"]
819    pub fn audio(&self) -> Capture<'_, AudioKind> {
820        Capture::new(self)
821    }
822
823    /// Capture **metadata** frames from this receiver.
824    ///
825    /// Returns a [`Capture`] view; see [`video`](Self::video) for the three
826    /// capture verbs it exposes ([`capture`](Capture::capture),
827    /// [`try_capture`](Capture::try_capture),
828    /// [`try_capture_ref`](Capture::try_capture_ref)).
829    ///
830    /// # Examples
831    ///
832    /// ```no_run
833    /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
834    /// # use std::time::Duration;
835    /// # fn main() -> Result<(), grafton_ndi::Error> {
836    /// # let ndi = NDI::new()?;
837    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
838    /// # let options = ReceiverOptions::builder(source).build();
839    /// # let receiver = Receiver::new(&ndi, &options)?;
840    /// if let Some(meta) = receiver.metadata().try_capture(Duration::from_millis(100))? {
841    ///     println!("metadata: {}", meta.data());
842    /// }
843    /// # Ok(())
844    /// # }
845    /// ```
846    #[must_use = "the Capture view does nothing until a capture verb is called"]
847    pub fn metadata(&self) -> Capture<'_, MetadataKind> {
848        Capture::new(self)
849    }
850
851    /// Single zero-copy poll backing every [`Capture::try_capture_ref`].
852    ///
853    /// Holds the shared capture guard for the duration of one
854    /// `NDIlib_recv_capture_v3` call, so it runs concurrently with other
855    /// captures but never overlaps a [`reconnect`](Self::reconnect).
856    fn capture_ref_kind<K: CaptureKind>(&self, timeout: Duration) -> Result<Option<K::Ref<'_>>> {
857        let timeout_ms = to_ms_checked(timeout)?;
858
859        // Shared guard: excludes a concurrent `reconnect`, not other captures.
860        let _capture = self.capture_lock();
861        // SAFETY: self.instance is a valid NDI receiver instance.
862        match unsafe { capture_raw::<K>(self.instance, timeout_ms) } {
863            CaptureResult::Frame(guard) => {
864                // Validation (e.g. FourCC) happens during ref construction.
865                let frame_ref = unsafe { K::make_ref(guard)? };
866                Ok(Some(frame_ref))
867            }
868            CaptureResult::None => Ok(None),
869            CaptureResult::Error => Err(Error::CaptureFailed("Received an error frame".into())),
870        }
871    }
872
873    /// Single owned poll backing every [`Capture::try_capture`].
874    pub(crate) fn try_capture_kind<K: CaptureKind>(
875        &self,
876        timeout: Duration,
877    ) -> Result<Option<K::Owned>> {
878        match self.capture_ref_kind::<K>(timeout)? {
879            Some(frame_ref) => Ok(Some(K::ref_to_owned(&frame_ref)?)),
880            None => Ok(None),
881        }
882    }
883
884    /// Retried owned capture backing every [`Capture::capture`].
885    ///
886    /// Polls [`try_capture_kind`](Self::try_capture_kind) under the default
887    /// [`RetryPolicy`], absorbing the SDK's initial-sync misses where it returns
888    /// `none` immediately instead of blocking for the full timeout.
889    pub(crate) fn capture_kind<K: CaptureKind>(&self, timeout: Duration) -> Result<K::Owned> {
890        retry_capture(timeout, &RetryPolicy::default(), |poll| {
891            self.try_capture_kind::<K>(poll)
892        })
893    }
894
895    /// Check if the receiver is still connected to its source.
896    ///
897    /// Returns `true` if there is at least one active connection to the source,
898    /// `false` otherwise. This can be used to detect when a source goes offline
899    /// or becomes unavailable.
900    ///
901    /// # Examples
902    ///
903    /// ```no_run
904    /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
905    /// # fn main() -> Result<(), grafton_ndi::Error> {
906    /// # let ndi = NDI::new()?;
907    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
908    /// # let options = ReceiverOptions::builder(source).build();
909    /// # let receiver = Receiver::new(&ndi, &options)?;
910    /// if receiver.is_connected() {
911    ///     println!("Still connected to source");
912    /// } else {
913    ///     println!("Lost connection to source");
914    /// }
915    /// # Ok(())
916    /// # }
917    /// ```
918    pub fn is_connected(&self) -> bool {
919        unsafe { NDIlib_recv_get_no_connections(self.instance) > 0 }
920    }
921
922    /// Re-establish this receiver's connection to its original source,
923    /// in place.
924    ///
925    /// Re-points the existing NDI receiver instance at the [`Source`] it
926    /// was created for **without** destroying and recreating the
927    /// receiver. This is intended for mid-session recovery: when a
928    /// source's feed has gone silent (for example an encoder-bound
929    /// NDI|HX camera that dropped its proxy stream under load), a
930    /// reconnect forces a fresh connection attempt while avoiding the
931    /// source-rediscovery round trip — and the discovery race — that
932    /// tearing down and rebuilding the receiver would incur.
933    ///
934    /// Liveness can be observed via [`Self::is_connected`] and
935    /// [`Self::connection_stats`] (`video_frames_received` resuming its
936    /// climb indicates frames are flowing again).
937    ///
938    /// # Thread safety
939    ///
940    /// `NDIlib_recv_connect` is the one receive call the SDK does *not* make
941    /// safe to run concurrently with `NDIlib_recv_capture_v3` on the same
942    /// instance. This method therefore takes the receiver's capture guard
943    /// exclusively: it **blocks until every in-flight capture on this receiver
944    /// returns**, and holds off any capture that starts while it runs. Captures
945    /// on the same receiver still run concurrently with each other; only a
946    /// reconnect is exclusive. Because it can block for as long as a capture's
947    /// timeout, prefer short capture timeouts on receivers you intend to
948    /// recover, and call it off the async runtime — `AsyncReceiver::reconnect`
949    /// does this for you.
950    ///
951    /// # Errors
952    ///
953    /// Returns an error only if the stored [`Source`] cannot be
954    /// re-marshalled to its FFI representation; `NDIlib_recv_connect`
955    /// itself reports no status, so a returned `Ok` means the reconnect was
956    /// *issued*, not that the feed has recovered — confirm recovery via
957    /// [`Self::connection_stats`].
958    pub fn reconnect(&self) -> Result<()> {
959        // Marshal before locking; this touches only `self.source`, not the
960        // instance, so it need not hold off captures.
961        let raw = self.source.to_raw()?;
962        // Exclusive guard: waits for in-flight captures to drain and blocks new
963        // ones, so `NDIlib_recv_connect` never overlaps `NDIlib_recv_capture_v3`.
964        let _exclusive = self
965            .capture_guard
966            .write()
967            .unwrap_or_else(PoisonError::into_inner);
968        // SAFETY: `self.instance` is a valid receiver instance for the
969        // lifetime of `self`, and `raw` (which owns the backing
970        // CStrings) outlives the call, so the pointers inside `raw.raw`
971        // stay valid while the SDK copies the source descriptor.
972        unsafe { NDIlib_recv_connect(self.instance, &raw.raw) };
973        Ok(())
974    }
975
976    /// Get the source this receiver is connected to.
977    ///
978    /// Returns a reference to the [`Source`] that was specified when creating
979    /// this receiver. This is useful for identifying which source a receiver
980    /// is associated with when managing multiple receivers.
981    ///
982    /// # Examples
983    ///
984    /// ```no_run
985    /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
986    /// # fn main() -> Result<(), grafton_ndi::Error> {
987    /// # let ndi = NDI::new()?;
988    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
989    /// # let options = ReceiverOptions::builder(source).build();
990    /// # let receiver = Receiver::new(&ndi, &options)?;
991    /// let source = receiver.source();
992    /// println!("Connected to: {name}", name = source.name);
993    /// # Ok(())
994    /// # }
995    /// ```
996    pub fn source(&self) -> &Source {
997        &self.source
998    }
999
1000    /// Get connection and performance statistics for this receiver.
1001    ///
1002    /// Provides detailed statistics including:
1003    /// - Number of active connections
1004    /// - Total frames received (video, audio, metadata)
1005    /// - Dropped frames (video, audio, metadata)
1006    /// - Queued frames waiting to be processed
1007    ///
1008    /// This is useful for monitoring receiver health and diagnosing
1009    /// performance issues in production applications.
1010    ///
1011    /// # Examples
1012    ///
1013    /// ```no_run
1014    /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
1015    /// # fn main() -> Result<(), grafton_ndi::Error> {
1016    /// # let ndi = NDI::new()?;
1017    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1018    /// # let options = ReceiverOptions::builder(source).build();
1019    /// # let receiver = Receiver::new(&ndi, &options)?;
1020    /// let stats = receiver.connection_stats();
1021    /// println!("Connections: {connections}", connections = stats.connections);
1022    /// println!("Video frames: {received} (dropped: {dropped})",
1023    ///          received = stats.video_frames_received,
1024    ///          dropped = stats.video_frames_dropped);
1025    /// println!("Frame drop rate: {rate:.2}%",
1026    ///          rate = stats.video_drop_percentage());
1027    /// # Ok(())
1028    /// # }
1029    /// ```
1030    pub fn connection_stats(&self) -> ConnectionStats {
1031        let connections = unsafe { NDIlib_recv_get_no_connections(self.instance) };
1032
1033        let mut total = NDIlib_recv_performance_t::default();
1034        let mut dropped = NDIlib_recv_performance_t::default();
1035        unsafe {
1036            NDIlib_recv_get_performance(self.instance, &mut total, &mut dropped);
1037        }
1038
1039        let mut queue = NDIlib_recv_queue_t::default();
1040        unsafe {
1041            NDIlib_recv_get_queue(self.instance, &mut queue);
1042        }
1043
1044        ConnectionStats {
1045            connections: connections.max(0) as u32,
1046            video_frames_received: total.video_frames.max(0) as u64,
1047            audio_frames_received: total.audio_frames.max(0) as u64,
1048            metadata_frames_received: total.metadata_frames.max(0) as u64,
1049            video_frames_dropped: dropped.video_frames.max(0) as u64,
1050            audio_frames_dropped: dropped.audio_frames.max(0) as u64,
1051            metadata_frames_dropped: dropped.metadata_frames.max(0) as u64,
1052            video_frames_queued: queue.video_frames.max(0) as u32,
1053            audio_frames_queued: queue.audio_frames.max(0) as u32,
1054            metadata_frames_queued: queue.metadata_frames.max(0) as u32,
1055        }
1056    }
1057
1058    /// Poll for status changes (tally, connections, etc.)
1059    ///
1060    /// # Arguments
1061    ///
1062    /// * `timeout` - Maximum time to wait for status change.
1063    ///   Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
1064    ///
1065    /// # Returns
1066    ///
1067    /// * `Some(ReceiverStatus)` - Status has changed
1068    /// * `None` - Timeout occurred with no status change
1069    ///
1070    /// # Errors
1071    ///
1072    /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
1073    pub fn poll_status_change(&self, timeout: Duration) -> Result<Option<ReceiverStatus>> {
1074        let timeout_ms = to_ms_checked(timeout)?;
1075        // Shared guard: excludes a concurrent `reconnect`, not other captures.
1076        let _capture = self.capture_lock();
1077        // SAFETY: NDI SDK documentation states that recv_capture_v3 is thread-safe
1078        let frame_type = unsafe {
1079            NDIlib_recv_capture_v3(
1080                self.instance,
1081                ptr::null_mut(), // no video
1082                ptr::null_mut(), // no audio
1083                ptr::null_mut(), // no metadata
1084                timeout_ms,
1085            )
1086        };
1087
1088        match frame_type {
1089            NDIlib_frame_type_e_NDIlib_frame_type_status_change => {
1090                // Note: NDI SDK doesn't provide recv_get_tally, so we can't query current tally state
1091                // We would need to track it from set_tally calls
1092                let tally = None;
1093
1094                // Get number of connections
1095                let connections = {
1096                    let conn_count = unsafe { NDIlib_recv_get_no_connections(self.instance) };
1097                    if conn_count >= 0 {
1098                        Some(conn_count)
1099                    } else {
1100                        None
1101                    }
1102                };
1103
1104                let has_tally = tally.is_some();
1105                let has_connections = connections.is_some();
1106
1107                Ok(Some(ReceiverStatus {
1108                    tally,
1109                    connections,
1110                    other: !has_tally && !has_connections,
1111                }))
1112            }
1113            _ => Ok(None),
1114        }
1115    }
1116}
1117
1118/// A typed view over a [`Receiver`] for capturing frames of one kind.
1119///
1120/// Created by [`Receiver::video`], [`Receiver::audio`], and
1121/// [`Receiver::metadata`]. The view is a cheap borrow of the receiver and does
1122/// nothing on its own — call one of its verbs to capture:
1123///
1124/// - [`capture`](Self::capture) — reliable owned capture with built-in retry.
1125///   **The primary method.** It absorbs the NDI SDK's initial-sync warm-up (the
1126///   first few polls after connecting return `none` immediately instead of
1127///   blocking), then runs with zero overhead in steady state, so it is safe in
1128///   a continuous capture loop.
1129/// - [`try_capture`](Self::try_capture) — a single owned poll; `Ok(None)` when
1130///   no frame is ready. For manual polling where you handle timing yourself.
1131/// - [`try_capture_ref`](Self::try_capture_ref) — a single zero-copy poll that
1132///   borrows the SDK's buffer in place (no allocation, no memcpy). The
1133///   recommended API for performance-critical, in-place processing: for a
1134///   1920×1080 BGRA frame it avoids ~8.3 MB of copying per frame (~475 MB/s at
1135///   60 fps). Convert to an owned frame with `to_owned` when you need to keep or
1136///   send it.
1137///
1138/// Every verb holds a shared capture guard for the duration of a single SDK
1139/// call, so captures of different kinds run concurrently with each other but
1140/// never overlap a [`Receiver::reconnect`].
1141pub struct Capture<'rx, K: CaptureKind> {
1142    rx: &'rx Receiver,
1143    _kind: PhantomData<K>,
1144}
1145
1146impl<'rx, K: CaptureKind> Capture<'rx, K> {
1147    fn new(rx: &'rx Receiver) -> Self {
1148        Self {
1149            rx,
1150            _kind: PhantomData,
1151        }
1152    }
1153
1154    /// Reliable owned capture: blocks up to `timeout`, retrying across the NDI
1155    /// SDK's initial-sync warm-up.
1156    ///
1157    /// `timeout` is a total retry budget; each individual SDK poll is capped to
1158    /// the remaining budget, and [`Duration::ZERO`] performs exactly one
1159    /// non-blocking attempt.
1160    ///
1161    /// # Arguments
1162    ///
1163    /// * `timeout` - Total time to wait for a frame. Must not exceed
1164    ///   [`crate::MAX_TIMEOUT`] (~49.7 days).
1165    ///
1166    /// # Errors
1167    ///
1168    /// Returns [`Error::FrameTimeout`] if no frame arrives within `timeout`;
1169    /// other errors propagate from the capture itself.
1170    pub fn capture(&self, timeout: Duration) -> Result<K::Owned> {
1171        self.rx.capture_kind::<K>(timeout)
1172    }
1173
1174    /// Single owned poll: returns `Ok(None)` if no frame is available within
1175    /// `timeout`.
1176    ///
1177    /// Prefer [`capture`](Self::capture) for reliable capture; this variant does
1178    /// not retry the SDK's warm-up misses.
1179    ///
1180    /// # Arguments
1181    ///
1182    /// * `timeout` - Maximum time to wait for a frame. Must not exceed
1183    ///   [`crate::MAX_TIMEOUT`] (~49.7 days).
1184    pub fn try_capture(&self, timeout: Duration) -> Result<Option<K::Owned>> {
1185        self.rx.try_capture_kind::<K>(timeout)
1186    }
1187
1188    /// Single zero-copy poll: returns a borrowed view of the SDK's buffer, or
1189    /// `Ok(None)` if no frame is available within `timeout`.
1190    ///
1191    /// The returned reference borrows the [`Receiver`] (not this temporary
1192    /// view), so it may outlive the `Capture` while keeping the receiver
1193    /// borrowed.
1194    ///
1195    /// # Arguments
1196    ///
1197    /// * `timeout` - Maximum time to wait for a frame. Must not exceed
1198    ///   [`crate::MAX_TIMEOUT`] (~49.7 days).
1199    pub fn try_capture_ref(&self, timeout: Duration) -> Result<Option<K::Ref<'rx>>> {
1200        // Bind through `'rx` so the returned borrow is tied to the receiver, not
1201        // to this short-lived view.
1202        let rx: &'rx Receiver = self.rx;
1203        rx.capture_ref_kind::<K>(timeout)
1204    }
1205}
1206
1207impl Drop for Receiver {
1208    fn drop(&mut self) {
1209        unsafe {
1210            NDIlib_recv_destroy(self.instance);
1211        }
1212    }
1213}
1214
1215/// # Safety
1216///
1217/// The NDI 6 SDK documentation explicitly states that recv operations are thread-safe.
1218/// `NDIlib_recv_capture_v3` and related functions use internal synchronization.
1219/// The Receiver struct only holds an opaque pointer returned by the SDK, and the SDK
1220/// guarantees that this pointer can be safely moved between threads.
1221unsafe impl Send for Receiver {}
1222
1223/// # Safety
1224///
1225/// The NDI 6 SDK documentation guarantees that `NDIlib_recv_capture_v3` is internally
1226/// synchronized and can be called concurrently from multiple threads. This is explicitly
1227/// mentioned in the SDK manual's thread safety section. The capture verbs (via
1228/// [`Receiver::video`], [`Receiver::audio`], and [`Receiver::metadata`]) can be
1229/// safely called from multiple threads simultaneously.
1230unsafe impl Sync for Receiver {}
1231
1232#[derive(Debug, Clone)]
1233pub struct ReceiverStatus {
1234    /// Current Tally (program/preview) if known
1235    pub tally: Option<Tally>,
1236    /// Number of active connections (None if unknown)
1237    pub connections: Option<i32>,
1238    /// True when the receiver reports any other change (latency, PTZ, etc.)
1239    pub other: bool,
1240}
1241
1242#[derive(Debug, Clone)]
1243pub struct Tally {
1244    pub on_program: bool,
1245    pub on_preview: bool,
1246}
1247
1248impl Tally {
1249    pub fn new(on_program: bool, on_preview: bool) -> Self {
1250        Tally {
1251            on_program,
1252            on_preview,
1253        }
1254    }
1255
1256    pub(crate) fn to_raw(&self) -> NDIlib_tally_t {
1257        NDIlib_tally_t {
1258            on_program: self.on_program,
1259            on_preview: self.on_preview,
1260        }
1261    }
1262}
1263
1264/// Connection and performance statistics for a receiver.
1265///
1266/// Provides detailed metrics about receiver health including connection count,
1267/// frame counts, and drop rates. Useful for monitoring and diagnostics.
1268#[derive(Debug, Clone)]
1269pub struct ConnectionStats {
1270    /// Number of active connections to this receiver
1271    pub connections: u32,
1272
1273    /// Total number of video frames received
1274    pub video_frames_received: u64,
1275
1276    /// Total number of audio frames received
1277    pub audio_frames_received: u64,
1278
1279    /// Total number of metadata frames received
1280    pub metadata_frames_received: u64,
1281
1282    /// Number of video frames dropped due to buffer overflow or processing delays
1283    pub video_frames_dropped: u64,
1284
1285    /// Number of audio frames dropped
1286    pub audio_frames_dropped: u64,
1287
1288    /// Number of metadata frames dropped
1289    pub metadata_frames_dropped: u64,
1290
1291    /// Number of video frames currently queued for processing
1292    pub video_frames_queued: u32,
1293
1294    /// Number of audio frames currently queued
1295    pub audio_frames_queued: u32,
1296
1297    /// Number of metadata frames currently queued
1298    pub metadata_frames_queued: u32,
1299}
1300
1301impl ConnectionStats {
1302    /// Calculate video frame drop percentage.
1303    ///
1304    /// Returns the percentage of video frames that were dropped out of the total
1305    /// received + dropped. Returns 0.0 if no frames have been received.
1306    ///
1307    /// # Examples
1308    ///
1309    /// ```
1310    /// # use grafton_ndi::ConnectionStats;
1311    /// let stats = ConnectionStats {
1312    ///     connections: 1,
1313    ///     video_frames_received: 900,
1314    ///     video_frames_dropped: 100,
1315    ///     audio_frames_received: 0,
1316    ///     audio_frames_dropped: 0,
1317    ///     metadata_frames_received: 0,
1318    ///     metadata_frames_dropped: 0,
1319    ///     video_frames_queued: 5,
1320    ///     audio_frames_queued: 0,
1321    ///     metadata_frames_queued: 0,
1322    /// };
1323    /// assert_eq!(stats.video_drop_percentage(), 10.0);
1324    /// ```
1325    pub fn video_drop_percentage(&self) -> f64 {
1326        let total = self.video_frames_received + self.video_frames_dropped;
1327        if total == 0 {
1328            0.0
1329        } else {
1330            (self.video_frames_dropped as f64 / total as f64) * 100.0
1331        }
1332    }
1333
1334    /// Calculate audio frame drop percentage.
1335    ///
1336    /// Returns the percentage of audio frames that were dropped out of the total
1337    /// received + dropped. Returns 0.0 if no frames have been received.
1338    pub fn audio_drop_percentage(&self) -> f64 {
1339        let total = self.audio_frames_received + self.audio_frames_dropped;
1340        if total == 0 {
1341            0.0
1342        } else {
1343            (self.audio_frames_dropped as f64 / total as f64) * 100.0
1344        }
1345    }
1346
1347    /// Calculate metadata frame drop percentage.
1348    ///
1349    /// Returns the percentage of metadata frames that were dropped out of the total
1350    /// received + dropped. Returns 0.0 if no frames have been received.
1351    pub fn metadata_drop_percentage(&self) -> f64 {
1352        let total = self.metadata_frames_received + self.metadata_frames_dropped;
1353        if total == 0 {
1354            0.0
1355        } else {
1356            (self.metadata_frames_dropped as f64 / total as f64) * 100.0
1357        }
1358    }
1359
1360    /// Check if the receiver is currently connected.
1361    ///
1362    /// Returns `true` if there is at least one active connection.
1363    pub fn is_connected(&self) -> bool {
1364        self.connections > 0
1365    }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371
1372    use std::cell::Cell;
1373
1374    fn test_source() -> Source {
1375        Source::default()
1376    }
1377
1378    #[derive(Default)]
1379    struct FakeClock {
1380        elapsed: Cell<Duration>,
1381    }
1382
1383    impl FakeClock {
1384        fn elapsed(&self) -> Duration {
1385            self.elapsed.get()
1386        }
1387
1388        fn advance(&self, duration: Duration) {
1389            self.elapsed.set(self.elapsed.get() + duration);
1390        }
1391    }
1392
1393    fn assert_raw_receiver_modes(
1394        raw: &RawRecvCreateV3,
1395        expected_color_format: NDIlib_recv_color_format_e,
1396        expected_bandwidth: NDIlib_recv_bandwidth_e,
1397    ) {
1398        assert_eq!(raw.raw.color_format, expected_color_format);
1399        assert_eq!(raw.raw.bandwidth, expected_bandwidth);
1400        assert_ne!(
1401            raw.raw.color_format,
1402            NDIlib_recv_color_format_e_NDIlib_recv_color_format_max
1403        );
1404        assert_ne!(
1405            raw.raw.bandwidth,
1406            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_max
1407        );
1408    }
1409
1410    #[test]
1411    fn receiver_color_format_maps_to_exact_sdk_values() {
1412        assert_eq!(
1413            NDIlib_recv_color_format_e::from(ReceiverColorFormat::BGRX_BGRA),
1414            NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA
1415        );
1416        assert_eq!(
1417            NDIlib_recv_color_format_e::from(ReceiverColorFormat::UYVY_BGRA),
1418            NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_BGRA
1419        );
1420        assert_eq!(
1421            NDIlib_recv_color_format_e::from(ReceiverColorFormat::RGBX_RGBA),
1422            NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA
1423        );
1424        assert_eq!(
1425            NDIlib_recv_color_format_e::from(ReceiverColorFormat::UYVY_RGBA),
1426            NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_RGBA
1427        );
1428        assert_eq!(
1429            NDIlib_recv_color_format_e::from(ReceiverColorFormat::Fastest),
1430            NDIlib_recv_color_format_e_NDIlib_recv_color_format_fastest
1431        );
1432        assert_eq!(
1433            NDIlib_recv_color_format_e::from(ReceiverColorFormat::Best),
1434            NDIlib_recv_color_format_e_NDIlib_recv_color_format_best
1435        );
1436    }
1437
1438    #[test]
1439    fn receiver_bandwidth_maps_to_exact_sdk_values() {
1440        assert_eq!(
1441            NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::MetadataOnly),
1442            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only
1443        );
1444        assert_eq!(
1445            NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::AudioOnly),
1446            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_audio_only
1447        );
1448        assert_eq!(
1449            NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::Lowest),
1450            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest
1451        );
1452        assert_eq!(
1453            NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::Highest),
1454            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest
1455        );
1456    }
1457
1458    #[test]
1459    fn receiver_options_defaults_emit_operational_raw_modes() {
1460        let raw = ReceiverOptions::builder(test_source())
1461            .build()
1462            .to_raw()
1463            .unwrap();
1464
1465        assert_raw_receiver_modes(
1466            &raw,
1467            NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA,
1468            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
1469        );
1470        assert!(raw.raw.allow_video_fields);
1471    }
1472
1473    #[test]
1474    fn snapshot_preset_emits_operational_raw_modes() {
1475        let raw = ReceiverOptionsBuilder::snapshot_preset(test_source())
1476            .build()
1477            .to_raw()
1478            .unwrap();
1479
1480        assert_raw_receiver_modes(
1481            &raw,
1482            NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA,
1483            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest,
1484        );
1485        assert!(!raw.raw.allow_video_fields);
1486    }
1487
1488    #[test]
1489    fn high_quality_preset_emits_operational_raw_modes() {
1490        let raw = ReceiverOptionsBuilder::high_quality_preset(test_source())
1491            .build()
1492            .to_raw()
1493            .unwrap();
1494
1495        assert_raw_receiver_modes(
1496            &raw,
1497            NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA,
1498            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
1499        );
1500        assert!(raw.raw.allow_video_fields);
1501    }
1502
1503    #[test]
1504    fn monitoring_preset_emits_operational_raw_modes() {
1505        let raw = ReceiverOptionsBuilder::monitoring_preset(test_source())
1506            .build()
1507            .to_raw()
1508            .unwrap();
1509
1510        assert_raw_receiver_modes(
1511            &raw,
1512            NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA,
1513            NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only,
1514        );
1515        assert!(!raw.raw.allow_video_fields);
1516    }
1517
1518    #[test]
1519    fn retry_caps_sub_poll_timeout_to_total_budget() {
1520        let clock = FakeClock::default();
1521        let policy = RetryPolicy {
1522            poll_interval: Duration::from_millis(100),
1523            sleep_between: Duration::from_millis(10),
1524        };
1525        let mut polls = Vec::new();
1526
1527        let result = retry_capture_with_clock(
1528            Duration::from_millis(25),
1529            &policy,
1530            |poll| {
1531                polls.push(poll);
1532                Ok(Some(42))
1533            },
1534            || clock.elapsed(),
1535            |_| {},
1536        );
1537
1538        assert_eq!(result.unwrap(), 42);
1539        assert_eq!(polls, [Duration::from_millis(25)]);
1540    }
1541
1542    #[test]
1543    fn retry_shrinks_later_poll_to_remaining_budget() {
1544        let clock = FakeClock::default();
1545        let policy = RetryPolicy {
1546            poll_interval: Duration::from_millis(40),
1547            sleep_between: Duration::from_millis(10),
1548        };
1549        let mut polls = Vec::new();
1550        let mut sleeps = Vec::new();
1551
1552        let result: Result<i32> = retry_capture_with_clock(
1553            Duration::from_millis(50),
1554            &policy,
1555            |poll| {
1556                polls.push(poll);
1557                if polls.len() == 1 {
1558                    clock.advance(Duration::from_millis(5));
1559                } else {
1560                    clock.advance(poll);
1561                }
1562                Ok(None)
1563            },
1564            || clock.elapsed(),
1565            |sleep| {
1566                sleeps.push(sleep);
1567                clock.advance(sleep);
1568            },
1569        );
1570
1571        assert!(matches!(
1572            result,
1573            Err(Error::FrameTimeout { attempts: 2, .. })
1574        ));
1575        assert_eq!(
1576            polls,
1577            [Duration::from_millis(40), Duration::from_millis(35)]
1578        );
1579        assert_eq!(sleeps, [Duration::from_millis(10)]);
1580    }
1581
1582    #[test]
1583    fn retry_zero_timeout_success_makes_one_nonblocking_attempt() {
1584        let clock = FakeClock::default();
1585        let mut polls = Vec::new();
1586
1587        let result = retry_capture_with_clock(
1588            Duration::ZERO,
1589            &RetryPolicy::default(),
1590            |poll| {
1591                polls.push(poll);
1592                Ok(Some(42))
1593            },
1594            || clock.elapsed(),
1595            |_| panic!("zero-timeout success should not sleep"),
1596        );
1597
1598        assert_eq!(result.unwrap(), 42);
1599        assert_eq!(polls, [Duration::ZERO]);
1600    }
1601
1602    #[test]
1603    fn retry_zero_timeout_miss_times_out_after_one_attempt() {
1604        let clock = FakeClock::default();
1605        let mut polls = Vec::new();
1606
1607        let result: Result<i32> = retry_capture_with_clock(
1608            Duration::ZERO,
1609            &RetryPolicy::default(),
1610            |poll| {
1611                polls.push(poll);
1612                Ok(None)
1613            },
1614            || clock.elapsed(),
1615            |_| panic!("zero-timeout miss should not sleep"),
1616        );
1617
1618        match result {
1619            Err(Error::FrameTimeout { attempts, elapsed }) => {
1620                assert_eq!(attempts, 1);
1621                assert_eq!(elapsed, Duration::ZERO);
1622            }
1623            _ => panic!("Expected FrameTimeout error"),
1624        }
1625        assert_eq!(polls, [Duration::ZERO]);
1626    }
1627
1628    #[test]
1629    fn retry_timeout_counts_only_actual_capture_attempts() {
1630        let clock = FakeClock::default();
1631        let policy = RetryPolicy {
1632            poll_interval: Duration::from_millis(8),
1633            sleep_between: Duration::from_millis(5),
1634        };
1635        let mut polls = Vec::new();
1636
1637        let result: Result<i32> = retry_capture_with_clock(
1638            Duration::from_millis(10),
1639            &policy,
1640            |poll| {
1641                polls.push(poll);
1642                clock.advance(Duration::from_millis(10));
1643                Ok(None)
1644            },
1645            || clock.elapsed(),
1646            |_| panic!("expired timeout should not sleep"),
1647        );
1648
1649        match result {
1650            Err(Error::FrameTimeout { attempts, elapsed }) => {
1651                assert_eq!(attempts, 1);
1652                assert_eq!(elapsed, Duration::from_millis(10));
1653            }
1654            _ => panic!("Expected FrameTimeout error"),
1655        }
1656        assert_eq!(polls, [Duration::from_millis(8)]);
1657    }
1658
1659    #[test]
1660    fn retry_caps_sleep_to_remaining_budget() {
1661        let clock = FakeClock::default();
1662        let policy = RetryPolicy {
1663            poll_interval: Duration::from_millis(8),
1664            sleep_between: Duration::from_millis(5),
1665        };
1666        let mut sleeps = Vec::new();
1667
1668        let result: Result<i32> = retry_capture_with_clock(
1669            Duration::from_millis(10),
1670            &policy,
1671            |poll| {
1672                assert_eq!(poll, Duration::from_millis(8));
1673                clock.advance(Duration::from_millis(8));
1674                Ok(None)
1675            },
1676            || clock.elapsed(),
1677            |sleep| {
1678                sleeps.push(sleep);
1679                clock.advance(sleep);
1680            },
1681        );
1682
1683        assert!(matches!(
1684            result,
1685            Err(Error::FrameTimeout { attempts: 1, .. })
1686        ));
1687        assert_eq!(sleeps, [Duration::from_millis(2)]);
1688    }
1689
1690    #[test]
1691    fn retry_validates_total_timeout_before_attempts() {
1692        let attempts = Cell::new(0);
1693
1694        let result: Result<i32> = retry_capture(
1695            crate::MAX_TIMEOUT + Duration::from_nanos(1),
1696            &RetryPolicy::default(),
1697            |_| {
1698                attempts.set(attempts.get() + 1);
1699                Ok(Some(42))
1700            },
1701        );
1702
1703        assert!(matches!(result, Err(Error::InvalidConfiguration(_))));
1704        assert_eq!(attempts.get(), 0);
1705    }
1706
1707    #[test]
1708    fn retry_succeeds_after_retry_before_timeout() {
1709        let clock = FakeClock::default();
1710        let attempts = Cell::new(0);
1711
1712        let result = retry_capture_with_clock(
1713            Duration::from_millis(50),
1714            &RetryPolicy::default(),
1715            |_| {
1716                attempts.set(attempts.get() + 1);
1717                if attempts.get() == 1 {
1718                    Ok(None)
1719                } else {
1720                    Ok(Some(42))
1721                }
1722            },
1723            || clock.elapsed(),
1724            |sleep| clock.advance(sleep),
1725        );
1726
1727        assert_eq!(result.unwrap(), 42);
1728        assert_eq!(attempts.get(), 2);
1729    }
1730
1731    #[test]
1732    fn retry_propagates_error() {
1733        let clock = FakeClock::default();
1734        let attempts = Cell::new(0);
1735        let mut sleeps = Vec::new();
1736
1737        let result: Result<i32> = retry_capture_with_clock(
1738            Duration::from_secs(1),
1739            &RetryPolicy::default(),
1740            |_| {
1741                attempts.set(attempts.get() + 1);
1742                Err(Error::CaptureFailed("test error".into()))
1743            },
1744            || clock.elapsed(),
1745            |sleep| sleeps.push(sleep),
1746        );
1747
1748        assert!(
1749            matches!(result, Err(Error::CaptureFailed(_))),
1750            "Should propagate CaptureFailed error"
1751        );
1752        assert_eq!(attempts.get(), 1);
1753        assert!(sleeps.is_empty());
1754    }
1755}