ui_storybook/components/recorder/
system_audio.rs1use leptos::prelude::*;
11
12use crate::components::primitives::{Badge, BadgeKind, Meter, ToggleSwitch};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct AppIconView {
18 pub id: String,
20 pub monogram: String,
22 pub color: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SystemAudioView {
29 pub enabled: bool,
31 pub expanded: bool,
33 pub selected_count: usize,
35 pub total_count: usize,
37 pub icon_stack: Vec<AppIconView>,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum AudioFilter {
46 All,
48 None,
50 Suggested,
52}
53
54impl AudioFilter {
55 #[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 #[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#[derive(Debug, Clone, PartialEq)]
78pub struct AudioAppView {
79 pub id: String,
81 pub name: String,
83 pub context: String,
85 pub selected: bool,
87 pub suggested: bool,
89 pub live: bool,
91 pub level: Option<f32>,
93 pub icon: AppIconView,
95}
96
97#[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
126fn 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#[component]
155pub fn SystemAudioAppList(
156 apps: Vec<AudioAppView>,
158 #[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#[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
226pub 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}