Skip to main content

ui_storybook/components/recorder/
device_picker.rs

1//! `DevicePickerMenu` + `DevicePickerRow` — expanded camera /
2//! microphone picker (M-UI.8 / AUT-128). Composes UI-03 menu
3//! primitives.
4
5use leptos::prelude::*;
6
7use crate::components::menus::{
8    MenuFooter, MenuList, MenuSection, PopoverPlacement, PopoverSurface,
9};
10use crate::components::primitives::{Badge, BadgeKind, Meter};
11
12use super::capture_source_row::CaptureSourceKind;
13
14/// Mock thumbnail content for a camera row — a CSS-positioned chip
15/// matching the device-card preview pattern from UI-07.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct DeviceThumb {
18    /// Background CSS color.
19    pub background: String,
20    /// Overlay glyph (1-2 chars).
21    pub glyph: String,
22}
23
24/// View-model for one option inside the picker.
25#[derive(Debug, Clone, PartialEq)]
26pub struct DeviceOptionView {
27    /// Stable id.
28    pub id: String,
29    /// Primary text.
30    pub name: String,
31    /// Secondary text (resolution, connection hint).
32    pub detail: String,
33    /// Optional small badge ("Wireless", "New").
34    pub badge: Option<String>,
35    /// `true` when this option is currently selected.
36    pub selected: bool,
37    /// Optional live audio level — microphone rows only.
38    pub level: Option<f32>,
39    /// Optional camera thumbnail.
40    pub thumbnail: Option<DeviceThumb>,
41}
42
43/// State of the picker beyond a normal device list.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum DevicePickerState {
46    /// One or more devices available.
47    Populated,
48    /// No devices detected.
49    Empty,
50    /// Permission has not been granted by the user yet.
51    PermissionNeeded,
52}
53
54#[component]
55pub fn DevicePickerMenu(
56    /// Whether this picker is for the camera or microphone slot.
57    kind: CaptureSourceKind,
58    /// Device list — typically `fixtures::devices::sample_camera_options()`.
59    /// Ignored when `state != Populated`.
60    devices: Vec<DeviceOptionView>,
61    /// Empty / permission-needed override.
62    #[prop(optional, default = DevicePickerState::Populated)]
63    state: DevicePickerState,
64) -> impl IntoView {
65    let title = match kind {
66        CaptureSourceKind::Camera => "Cameras",
67        CaptureSourceKind::Microphone => "Microphones",
68    };
69    let footer_action = match kind {
70        CaptureSourceKind::Camera => "Connect new camera",
71        CaptureSourceKind::Microphone => "Pair Bluetooth microphone",
72    };
73    let rows: Vec<_> = if matches!(state, DevicePickerState::Populated) {
74        devices.into_iter().map(render_row).collect()
75    } else {
76        Vec::new()
77    };
78    let body = match state {
79        DevicePickerState::Populated => view! {
80            <MenuList label=title.to_string()>
81                <MenuSection heading="Available".to_string()>{rows}</MenuSection>
82            </MenuList>
83        }
84        .into_any(),
85        DevicePickerState::Empty => view! {
86            <div class="device-picker-state">
87                <div class="device-picker-state-glyph" aria-hidden="true">"⚠"</div>
88                <div class="device-picker-state-title">"No devices detected"</div>
89                <div class="device-picker-state-subtitle">
90                    "Plug in or pair a device, then re-open this menu."
91                </div>
92            </div>
93        }
94        .into_any(),
95        DevicePickerState::PermissionNeeded => view! {
96            <div class="device-picker-state">
97                <div class="device-picker-state-glyph device-picker-state-glyph-warn" aria-hidden="true">"⚠"</div>
98                <div class="device-picker-state-title">
99                    {match kind {
100                        CaptureSourceKind::Camera => "Camera access required",
101                        CaptureSourceKind::Microphone => "Microphone access required",
102                    }}
103                </div>
104                <div class="device-picker-state-subtitle">
105                    "Grant access in System Settings → Privacy & Security, then re-open this menu."
106                </div>
107            </div>
108        }
109        .into_any(),
110    };
111    view! {
112        <PopoverSurface
113            placement=PopoverPlacement::BottomLeft
114            width_px=320_u16
115            title=title.to_string()
116            footer=ToChildren::to_children(move || view! {
117                <MenuFooter>
118                    <span style="font-size:11px;color:var(--text-tertiary)">"Devices update live."</span>
119                    <button class="btn btn-default btn-sm">{footer_action}</button>
120                </MenuFooter>
121            })
122        >
123            {body}
124        </PopoverSurface>
125    }
126}
127
128fn render_row(opt: DeviceOptionView) -> impl IntoView {
129    let mut row_class = String::from("device-picker-row");
130    if opt.selected {
131        row_class.push_str(" device-picker-row-selected");
132    }
133    let badge = opt
134        .badge
135        .map(|b| view! { <Badge kind=BadgeKind::Plan>{b}</Badge> });
136    let level_view = opt.level.map(|l| view! { <Meter level=l bar_count=10 /> });
137    let thumb_view = opt.thumbnail.as_ref().map(|t| {
138        let style = format!("background:{}", t.background);
139        let glyph = t.glyph.clone();
140        view! {
141            <span class="device-picker-thumb" style=style aria-hidden="true">
142                <span class="device-picker-thumb-glyph">{glyph}</span>
143            </span>
144        }
145    });
146    view! {
147        <li class="device-picker-row-item" role="none">
148            <button class=row_class role="menuitem" aria-pressed=opt.selected>
149                {thumb_view}
150                <span class="device-picker-row-text">
151                    <span class="device-picker-row-name">{opt.name}</span>
152                    <span class="device-picker-row-detail">{opt.detail}</span>
153                </span>
154                {badge}
155                {level_view.map(|l| view! { <span class="device-picker-row-meter">{l}</span> })}
156                {opt.selected.then(|| view! {
157                    <span class="device-picker-row-check" aria-hidden="true">"✓"</span>
158                })}
159            </button>
160        </li>
161    }
162}