Skip to main content

ui_storybook/components/recorder/
system_audio.rs

1//! `SystemAudioRow` + `SystemAudioAppList` (M-UI.9 / AUT-129) — the
2//! tray popover's system-audio section. Collapsed = one row showing
3//! the currently-selected count + overlapping app icons + toggle.
4//! Expanded = a filter row (All / None / Suggested) + per-app rows
5//! with selection checkboxes + live meters.
6//!
7//! Stateless: selection set is a prop. Callbacks would land in
8//! `app-ui` per the presentational contract.
9
10use leptos::prelude::*;
11
12use crate::components::primitives::{Badge, BadgeKind, Meter, ToggleSwitch};
13
14/// View-model for one app icon — used both in the collapsed stack
15/// and inside expanded `AudioAppRow`s.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct AppIconView {
18    /// Stable id.
19    pub id: String,
20    /// 1- or 2-letter monogram drawn inside the tile.
21    pub monogram: String,
22    /// CSS background color string.
23    pub color: String,
24}
25
26/// View-model for the collapsed `SystemAudioRow`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SystemAudioView {
29    /// `true` when system audio capture is on.
30    pub enabled: bool,
31    /// `true` when the expanded picker is open below this row.
32    pub expanded: bool,
33    /// How many apps are currently selected.
34    pub selected_count: usize,
35    /// How many apps are available in total.
36    pub total_count: usize,
37    /// Apps to render in the overlapping leading-icon stack. The list
38    /// can be longer than [`ICON_STACK_MAX`] — the row truncates and
39    /// shows an overflow pill.
40    pub icon_stack: Vec<AppIconView>,
41}
42
43/// Filter chip kind for the expanded list.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum AudioFilter {
46    /// Selects every app.
47    All,
48    /// Clears selection.
49    None,
50    /// Selects the system-suggested subset (apps emitting audio now).
51    Suggested,
52}
53
54impl AudioFilter {
55    /// Display label.
56    #[must_use]
57    pub fn label(self) -> &'static str {
58        match self {
59            AudioFilter::All => "All",
60            AudioFilter::None => "None",
61            AudioFilter::Suggested => "Suggested",
62        }
63    }
64
65    /// Stable kebab-case slug.
66    #[must_use]
67    pub fn slug(self) -> &'static str {
68        match self {
69            AudioFilter::All => "all",
70            AudioFilter::None => "none",
71            AudioFilter::Suggested => "suggested",
72        }
73    }
74}
75
76/// One app row inside the expanded list.
77#[derive(Debug, Clone, PartialEq)]
78pub struct AudioAppView {
79    /// Stable id (matches the underlying app handle).
80    pub id: String,
81    /// Display name (`"Spotify"`, `"Chrome"`).
82    pub name: String,
83    /// Secondary context (`"Discovery Weekly · 18 m left"`, `"YouTube"`).
84    pub context: String,
85    /// `true` when this app is in the active selection set.
86    pub selected: bool,
87    /// `true` to render the "Suggested" badge.
88    pub suggested: bool,
89    /// `true` to render the pulsing red LIVE dot + label.
90    pub live: bool,
91    /// Optional normalized audio level `[0, 1]` for the per-app meter.
92    pub level: Option<f32>,
93    /// Leading icon.
94    pub icon: AppIconView,
95}
96
97/// Collapsed system-audio row.
98#[component]
99pub fn SystemAudioRow(view: SystemAudioView) -> impl IntoView {
100    let chevron_class = if view.expanded {
101        "system-audio-chevron system-audio-chevron-open"
102    } else {
103        "system-audio-chevron"
104    };
105    let count_label = format_selection_count(view.selected_count, view.total_count);
106    let stack = build_icon_stack(view.icon_stack);
107    view! {
108        <div class="system-audio-row">
109            <span class="system-audio-leading">{stack}</span>
110            <span class="system-audio-text">
111                <span class="system-audio-title">"System audio"</span>
112                <span class="system-audio-subtitle">{count_label}</span>
113            </span>
114            <span class="system-audio-toggle">
115                <ToggleSwitch checked=view.enabled label="Enable system audio".to_string() />
116            </span>
117            <button class=chevron_class aria-label="Expand system audio app list" aria-expanded=view.expanded>
118                "▾"
119            </button>
120        </div>
121    }
122}
123
124const ICON_STACK_MAX_VISIBLE: usize = 3;
125
126/// Stack at most [`ICON_STACK_MAX_VISIBLE`] icons + an overflow pill
127/// for the rest.
128fn build_icon_stack(icons: Vec<AppIconView>) -> AnyView {
129    let visible: Vec<_> = icons.iter().take(ICON_STACK_MAX_VISIBLE).cloned().collect();
130    let overflow = icons.len().saturating_sub(visible.len());
131    let visible_views = visible
132        .into_iter()
133        .map(|icon| {
134            let style = format!("background:{}", icon.color);
135            let monogram = icon.monogram.clone();
136            view! {
137                <span class="system-audio-icon" style=style aria-hidden="true">{monogram}</span>
138            }
139        })
140        .collect_view();
141    view! {
142        <span class="system-audio-icon-stack">
143            {visible_views}
144            {(overflow > 0).then(|| {
145                let label = format!("+{overflow}");
146                view! { <span class="system-audio-icon-overflow" aria-label=format!("{overflow} more")>{label}</span> }
147            })}
148        </span>
149    }
150    .into_any()
151}
152
153/// Expanded list — filter row + per-app rows.
154#[component]
155pub fn SystemAudioAppList(
156    /// All apps available for selection, in display order.
157    apps: Vec<AudioAppView>,
158    /// Currently-active filter chip. Cosmetic only; the parent decides
159    /// which apps are selected (this prop is purely visual highlight).
160    #[prop(optional, default = AudioFilter::All)]
161    active_filter: AudioFilter,
162) -> impl IntoView {
163    let app_rows: Vec<_> = apps.into_iter().map(render_app_row).collect();
164    let chips: Vec<_> = [AudioFilter::All, AudioFilter::None, AudioFilter::Suggested]
165        .iter()
166        .map(|f| {
167            let mut class = String::from("system-audio-filter");
168            if *f == active_filter {
169                class.push_str(" system-audio-filter-active");
170            }
171            let slug = f.slug();
172            let label = f.label();
173            view! {
174                <button class=class data-filter=slug aria-pressed=*f == active_filter>{label}</button>
175            }
176        })
177        .collect();
178    view! {
179        <div class="system-audio-applist">
180            <div class="system-audio-filters" role="toolbar" aria-label="Filter audio apps">{chips}</div>
181            <ul class="system-audio-rows" role="listbox" aria-label="Audio apps">{app_rows}</ul>
182        </div>
183    }
184}
185
186fn render_app_row(app: AudioAppView) -> impl IntoView {
187    let mut class = String::from("audio-app-row");
188    if app.selected {
189        class.push_str(" audio-app-row-selected");
190    }
191    if app.live {
192        class.push_str(" audio-app-row-live");
193    }
194    let icon_style = format!("background:{}", app.icon.color);
195    let monogram = app.icon.monogram.clone();
196    let level_view = app.level.map(|l| view! { <Meter level=l bar_count=8 /> });
197    view! {
198        <li class=class role="option" aria-selected=app.selected>
199            <span class=if app.selected { "audio-app-check audio-app-check-on" } else { "audio-app-check" } aria-hidden="true">
200                {if app.selected { "✓" } else { "" }}
201            </span>
202            <span class="audio-app-icon" style=icon_style aria-hidden="true">{monogram}</span>
203            <span class="audio-app-text">
204                <span class="audio-app-name">{app.name}</span>
205                <span class="audio-app-context">{app.context}</span>
206            </span>
207            {app.suggested.then(|| view! { <Badge kind=BadgeKind::Accent>"Suggested"</Badge> })}
208            {app.live.then(|| view! {
209                <span class="audio-app-live" aria-label="Live audio">
210                    <span class="audio-app-live-dot" aria-hidden="true"></span>
211                    "LIVE"
212                </span>
213            })}
214            {level_view.map(|m| view! { <span class="audio-app-meter">{m}</span> })}
215        </li>
216    }
217}
218
219/// Format `"N of M apps"` (plural-aware).
220#[must_use]
221pub fn format_selection_count(selected: usize, total: usize) -> String {
222    let apps = if total == 1 { "app" } else { "apps" };
223    format!("{selected} of {total} {apps}")
224}
225
226/// Maximum visible icons before the stack collapses to an overflow
227/// pill. Module-level so tests + downstream consumers can read it
228/// without a Leptos-component receiver (components are functions, not
229/// types — `impl Component {}` won't compile).
230pub const ICON_STACK_MAX: usize = ICON_STACK_MAX_VISIBLE;
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn selection_count_pluralises() {
238        assert_eq!(format_selection_count(0, 7), "0 of 7 apps");
239        assert_eq!(format_selection_count(4, 7), "4 of 7 apps");
240        assert_eq!(format_selection_count(7, 7), "7 of 7 apps");
241        assert_eq!(format_selection_count(1, 1), "1 of 1 app");
242        assert_eq!(format_selection_count(0, 1), "0 of 1 app");
243    }
244
245    #[test]
246    fn icon_stack_max_is_three() {
247        assert_eq!(ICON_STACK_MAX, 3);
248    }
249
250    #[test]
251    fn audio_filter_slugs_unique() {
252        let slugs = [
253            AudioFilter::All.slug(),
254            AudioFilter::None.slug(),
255            AudioFilter::Suggested.slug(),
256        ];
257        let mut sorted = slugs.to_vec();
258        sorted.sort_unstable();
259        sorted.dedup();
260        assert_eq!(sorted.len(), slugs.len());
261    }
262}