Skip to main content

ui_storybook/components/recorder/
capture_source_row.rs

1//! `CaptureSourceRow` — collapsed camera / microphone row in the tray
2//! record popover (M-UI.8 / AUT-128).
3//!
4//! Composes UI-01 `IconTile`, UI-04 `ToggleSwitch`, and UI-04 `Meter`.
5//! Stateless: `enabled` / `expanded` / `level` are props; the parent
6//! re-renders with new values when callbacks fire.
7
8use leptos::prelude::*;
9
10use crate::components::primitives::{Camera, IconTile, IconTileKind, Meter, Mic, ToggleSwitch};
11
12/// Kind of capture source — drives the leading icon + the
13/// `aria-label` text on the toggle.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum CaptureSourceKind {
16    /// Camera device.
17    Camera,
18    /// Microphone device.
19    Microphone,
20}
21
22impl CaptureSourceKind {
23    /// Accessible label noun.
24    #[must_use]
25    pub fn label(self) -> &'static str {
26        match self {
27            CaptureSourceKind::Camera => "camera",
28            CaptureSourceKind::Microphone => "microphone",
29        }
30    }
31}
32
33/// View-model for one capture-source row.
34#[derive(Debug, Clone, PartialEq)]
35pub struct CaptureSourceView {
36    /// Stable id matching the underlying device.
37    pub id: String,
38    /// Camera vs microphone.
39    pub kind: CaptureSourceKind,
40    /// Primary title (`"FaceTime HD Camera"`).
41    pub title: String,
42    /// Secondary line (`"MacBook · 1080p"`, `"Bluetooth · 86%"`).
43    pub subtitle: String,
44    /// `true` when the capture source is on.
45    pub enabled: bool,
46    /// `true` when the picker popover is open for this row.
47    pub expanded: bool,
48    /// `true` to render the favourited star glyph.
49    pub favorite: bool,
50    /// Optional normalized audio level `[0, 1]`. Renders a `Meter`
51    /// when `Some` and `kind == Microphone`. Cameras ignore this.
52    pub level: Option<f32>,
53}
54
55#[component]
56pub fn CaptureSourceRow(view: CaptureSourceView) -> impl IntoView {
57    let mut class = String::from("capture-source-row");
58    if view.expanded {
59        class.push_str(" capture-source-row-expanded");
60    }
61    if !view.enabled {
62        class.push_str(" capture-source-row-off");
63    }
64    let chevron_class = if view.expanded {
65        "capture-source-chevron capture-source-chevron-open"
66    } else {
67        "capture-source-chevron"
68    };
69    let kind_label = view.kind.label();
70    let toggle_label = format!("Enable {kind_label}");
71    let meter_view = if view.kind == CaptureSourceKind::Microphone {
72        view.level
73            .map(|level| view! { <Meter level=level bar_count=10 /> })
74    } else {
75        None
76    };
77    view! {
78        <div class=class data-kind=match view.kind {
79            CaptureSourceKind::Camera => "camera",
80            CaptureSourceKind::Microphone => "microphone",
81        }>
82            <span class="capture-source-leading">
83                <IconTile kind=IconTileKind::Device>
84                    {match view.kind {
85                        CaptureSourceKind::Camera => view! { <Camera /> }.into_any(),
86                        CaptureSourceKind::Microphone => view! { <Mic /> }.into_any(),
87                    }}
88                </IconTile>
89            </span>
90            <span class="capture-source-text">
91                <span class="capture-source-title">
92                    {view.title}
93                    {view.favorite.then(|| view! {
94                        <span class="capture-source-star" aria-label="Favourite" title="Favourite">"★"</span>
95                    })}
96                </span>
97                <span class="capture-source-subtitle">{view.subtitle}</span>
98            </span>
99            {meter_view.map(|m| view! { <span class="capture-source-meter">{m}</span> })}
100            <span class="capture-source-toggle">
101                <ToggleSwitch checked=view.enabled label=toggle_label />
102            </span>
103            <button class=chevron_class aria-label="Expand device picker" aria-expanded=view.expanded>
104                "▾"
105            </button>
106        </div>
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn each_kind_has_a_unique_label() {
116        assert_ne!(
117            CaptureSourceKind::Camera.label(),
118            CaptureSourceKind::Microphone.label(),
119        );
120    }
121
122    #[test]
123    fn labels_are_lowercase_singular() {
124        for k in [CaptureSourceKind::Camera, CaptureSourceKind::Microphone] {
125            let l = k.label();
126            assert_eq!(l.to_lowercase(), l);
127            // We use the noun in "Enable {noun}" — must not already
128            // start uppercase or include "the".
129            assert!(!l.starts_with("the "));
130        }
131    }
132}