1use std::rc::Rc;
27
28use gpui::{
29 App, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
30 Window, canvas, div, point, prelude::FluentBuilder, px,
31};
32use gpui_kit_semantics::{NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, TypeScale};
34
35use crate::content::transport::{TransportBar, TransportDuration, TransportEvent};
36use crate::display::badge::Badge;
37use crate::foundation::{Disableable, Ident, StyledExt, text};
38use crate::media::notice;
39use crate::media::transport::{MediaAvailability, MediaCommand, MediaEvent, MediaTransport};
40use crate::strings::{ActiveStrings, StringKey};
41
42const PEAKS_HEIGHT: f32 = 56.0;
45const PEAK_GAP: f32 = 1.0;
46
47const NOTICE_HEIGHT: f32 = 96.0;
49
50const PEAK_FLOOR: f32 = 0.06;
53
54type EventHandler = Rc<dyn Fn(&MediaEvent, &mut Window, &mut App)>;
55
56#[derive(IntoElement)]
58pub struct AudioPlayer {
59 ident: Ident,
60 title: Option<SharedString>,
61 subtitle: Option<SharedString>,
62 transport: Option<Rc<dyn MediaTransport>>,
63 elapsed: Option<SharedString>,
64 remaining: Option<SharedString>,
65 peaks: Vec<f32>,
66 step: Option<f32>,
67 speeds: Vec<f32>,
68 disabled: bool,
69 on_event: Option<EventHandler>,
70}
71
72impl std::fmt::Debug for AudioPlayer {
73 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 formatter
75 .debug_struct("AudioPlayer")
76 .field("ident", &self.ident)
77 .field("title", &self.title)
78 .field("transport", &self.transport)
79 .field("peaks", &self.peaks.len())
80 .field("disabled", &self.disabled)
81 .field("has_handler", &self.on_event.is_some())
82 .finish()
83 }
84}
85
86impl AudioPlayer {
87 pub fn new(ident: impl Into<Ident>) -> Self {
89 Self {
90 ident: ident.into(),
91 title: None,
92 subtitle: None,
93 transport: None,
94 elapsed: None,
95 remaining: None,
96 peaks: Vec::new(),
97 step: None,
98 speeds: Vec::new(),
99 disabled: false,
100 on_event: None,
101 }
102 }
103
104 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
106 self.title = Some(title.into());
107 self
108 }
109
110 pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
112 self.subtitle = Some(subtitle.into());
113 self
114 }
115
116 pub fn transport(mut self, transport: Rc<dyn MediaTransport>) -> Self {
121 self.transport = Some(transport);
122 self
123 }
124
125 pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
127 self.elapsed = Some(elapsed.into());
128 self
129 }
130
131 pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
133 self.remaining = Some(remaining.into());
134 self
135 }
136
137 pub fn peaks(mut self, peaks: impl IntoIterator<Item = f32>) -> Self {
143 self.peaks = peaks.into_iter().map(|peak| peak.clamp(0.0, 1.0)).collect();
144 self
145 }
146
147 pub fn step_seconds(mut self, seconds: f32) -> Self {
149 self.step = Some(seconds.max(0.0));
150 self
151 }
152
153 pub fn speeds(mut self, speeds: impl IntoIterator<Item = f32>) -> Self {
155 self.speeds = speeds.into_iter().filter(|speed| *speed > 0.0).collect();
156 self
157 }
158
159 pub fn on_event(
160 mut self,
161 handler: impl Fn(&MediaEvent, &mut Window, &mut App) + 'static,
162 ) -> Self {
163 self.on_event = Some(Rc::new(handler));
164 self
165 }
166}
167
168impl Disableable for AudioPlayer {
169 fn disabled(mut self, disabled: bool) -> Self {
170 self.disabled = disabled;
171 self
172 }
173}
174
175impl RenderOnce for AudioPlayer {
176 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
177 let theme = cx.theme().clone();
178 let ident = self.ident.clone();
179 let strings = cx.strings().clone();
180
181 let snapshot = self
182 .transport
183 .as_ref()
184 .map(|transport| transport.snapshot());
185 let origin = self.transport.as_ref().map(|transport| transport.origin());
186 let state = match &snapshot {
187 Some(snapshot) => snapshot.availability.name(),
188 None => NO_TRANSPORT,
189 };
190
191 let titles = div()
192 .column()
193 .gap_token(&theme, Space::Xs)
194 .children(
195 self.title
196 .clone()
197 .map(|title| text(&theme, TypeScale::Subtitle, title)),
198 )
199 .children(self.subtitle.clone().map(|subtitle| {
200 text(&theme, TypeScale::Caption, subtitle).text_tone(&theme, TextTone::Muted)
201 }));
202 let titles = match origin {
205 Some(origin) => titles
206 .semantic_in(
207 cx,
208 NodeSpec::new(ident.child("origin").semantic_id(), Role::Status)
209 .parent(ident.semantic_id())
210 .value(origin.name()),
211 )
212 .into_any_element(),
213 None => titles.into_any_element(),
214 };
215
216 let heading = div()
217 .row()
218 .w_full()
219 .items_start()
220 .justify_between()
221 .gap_token(&theme, Space::Sm)
222 .child(titles)
223 .children(
224 origin
225 .filter(|origin| origin.is_fixture())
226 .map(|_| Badge::new(strings.text(StringKey::MediaFixture)).warning()),
227 );
228
229 let framed = |body: gpui::AnyElement| {
230 div()
231 .relative()
232 .w_full()
233 .h(px(NOTICE_HEIGHT))
234 .radius(&theme, Radius::Card)
235 .frame(&theme, Surface::Raised, Elevation::Raised)
236 .child(body)
237 };
238
239 let actionable = !self.disabled
240 && snapshot
241 .as_ref()
242 .is_some_and(|snapshot| snapshot.availability.is_ready());
243
244 let body = match (&snapshot, &self.transport) {
245 (Some(snapshot), Some(transport)) if snapshot.availability.is_ready() => {
246 let fraction = snapshot
247 .duration
248 .seconds()
249 .map(|total| (snapshot.position / total).clamp(0.0, 1.0));
250 let mut ready = div().column().w_full().gap_token(&theme, Space::Sm);
251 if !self.peaks.is_empty() {
252 ready = ready.child(
253 div()
254 .w_full()
255 .h(px(PEAKS_HEIGHT))
256 .child(peak_band(
257 self.peaks.clone(),
258 fraction,
259 theme.colors.accent,
260 theme.colors.hairline_strong,
261 ))
262 .semantic_in(
263 cx,
264 NodeSpec::new(ident.child("peaks").semantic_id(), Role::Image)
265 .parent(ident.semantic_id())
266 .text(strings.text(StringKey::MediaWaveform))
267 .value(self.peaks.len().to_string()),
268 ),
269 );
270 }
271 ready
272 .child(bar(
273 &ident,
274 &self,
275 snapshot,
276 Rc::clone(transport),
277 actionable,
278 ))
279 .into_any_element()
280 }
281 (Some(snapshot), _) => framed(unready(
282 &theme,
283 &strings,
284 &snapshot.availability,
285 self.title.clone(),
286 super::NoticePlace::Middle,
287 ))
288 .into_any_element(),
289 (None, _) => framed(notice(
290 &theme,
291 theme.colors.warning,
292 self.title
293 .clone()
294 .unwrap_or_else(|| strings.text(StringKey::MediaNoTransport)),
295 strings.text(StringKey::MediaNoTransportDetail),
296 ))
297 .into_any_element(),
298 };
299
300 let mut spec = NodeSpec::new(ident.semantic_id(), Role::Group)
301 .disabled(self.disabled)
302 .busy(matches!(
303 snapshot.as_ref().map(|snapshot| &snapshot.availability),
304 Some(MediaAvailability::Loading)
305 ))
306 .invalid(matches!(
307 snapshot.as_ref().map(|snapshot| &snapshot.availability),
308 Some(MediaAvailability::Failed(_))
309 ))
310 .value(state);
311 if let Some(title) = self.title.clone() {
312 spec = spec.text(title);
313 }
314
315 div()
316 .id(ident.element_id())
317 .column()
318 .w_full()
319 .gap_token(&theme, Space::Sm)
320 .when(self.disabled, |element| {
321 element.opacity(theme.opacity.disabled)
322 })
323 .child(heading)
324 .child(body)
325 .semantic_in(cx, spec)
326 }
327}
328
329const NO_TRANSPORT: &str = "no-transport";
334
335fn bar(
337 ident: &Ident,
338 player: &AudioPlayer,
339 snapshot: &crate::media::transport::MediaSnapshot,
340 transport: Rc<dyn MediaTransport>,
341 actionable: bool,
342) -> TransportBar {
343 let mut bar = TransportBar::new(ident.child("transport"))
344 .state(snapshot.state)
345 .position(snapshot.position)
346 .volume(snapshot.volume)
347 .muted(snapshot.muted)
348 .buffered(snapshot.buffered.iter().copied())
349 .disabled(!actionable);
350 bar = match snapshot.duration {
351 TransportDuration::Known(seconds) => bar.duration(seconds),
352 TransportDuration::Unknown => bar.unknown_duration(),
353 };
354 if let Some(elapsed) = player.elapsed.clone() {
355 bar = bar.elapsed(elapsed);
356 }
357 if let Some(remaining) = player.remaining.clone() {
358 bar = bar.remaining(remaining);
359 }
360 if let Some(step) = player.step {
361 bar = bar.step_seconds(step);
362 }
363 if !player.speeds.is_empty() {
364 bar = bar.speeds(player.speeds.iter().copied(), snapshot.speed);
365 }
366 if actionable {
367 let handler = player.on_event.clone();
368 bar = bar.on_event(move |event, window, cx| {
369 let Some(command) = command_for(event) else {
370 return;
371 };
372 let outcome = transport.apply(command);
373 if let Some(handler) = &handler {
374 handler(&MediaEvent::of(command, outcome), window, cx);
375 }
376 window.refresh();
379 });
380 }
381 bar
382}
383
384pub(crate) fn command_for(event: &TransportEvent) -> Option<MediaCommand> {
389 Some(match event {
390 TransportEvent::PlayRequested => MediaCommand::Play,
391 TransportEvent::PauseRequested => MediaCommand::Pause,
392 TransportEvent::SeekRequested(seconds) => MediaCommand::Seek(*seconds),
393 TransportEvent::VolumeRequested(volume) => MediaCommand::SetVolume(*volume),
394 TransportEvent::MuteToggled => MediaCommand::ToggleMute,
395 TransportEvent::SpeedRequested(speed) => MediaCommand::SetSpeed(*speed),
396 TransportEvent::Stepped(step) => MediaCommand::Step(*step),
397 TransportEvent::SeekPreview(_) => return None,
398 })
399}
400
401pub(crate) fn unready(
403 theme: &gpui_kit_theme::Theme,
404 strings: &crate::strings::Strings,
405 availability: &MediaAvailability,
406 title: Option<SharedString>,
407 place: super::NoticePlace,
408) -> gpui::AnyElement {
409 let (tint, key) = match availability {
410 MediaAvailability::Loading => (theme.colors.text_muted, StringKey::Loading),
411 MediaAvailability::NoBackend(_) => (theme.colors.warning, StringKey::MediaNoBackend),
412 MediaAvailability::Failed(_) => (theme.colors.danger, StringKey::MediaFailed),
413 _ => (theme.colors.text_muted, StringKey::MediaEmpty),
414 };
415 let headline = title.unwrap_or_else(|| strings.text(key));
416 let detail = availability.reason().unwrap_or_else(|| strings.text(key));
419 super::notice_at(theme, tint, headline, detail, place)
420}
421
422fn peak_band(
424 peaks: Vec<f32>,
425 fraction: Option<f32>,
426 played: Hsla,
427 ahead: Hsla,
428) -> impl IntoElement {
429 canvas(
430 |_, _, _| {},
431 move |bounds, _, window, _| {
432 let count = peaks.len();
433 if count == 0 || bounds.size.width <= px(0.0) {
434 return;
435 }
436 let width = f32::from(bounds.size.width) / count as f32;
437 let bar = (width - PEAK_GAP).max(0.5);
438 let height = f32::from(bounds.size.height);
439 let centre = f32::from(bounds.origin.y) + height / 2.0;
440 let head = fraction.map(|fraction| fraction * count as f32);
443 for (index, peak) in peaks.iter().enumerate() {
444 let extent = (peak.clamp(0.0, 1.0).max(PEAK_FLOOR) * height / 2.0).max(0.5);
445 let left = f32::from(bounds.origin.x) + index as f32 * width;
446 let color = match head {
447 Some(head) if (index as f32) < head => played,
448 _ => ahead,
449 };
450 window.paint_quad(gpui::fill(
451 gpui::Bounds {
452 origin: point(px(left), px(centre - extent)),
453 size: gpui::size(px(bar), px(extent * 2.0)),
454 },
455 color,
456 ));
457 }
458 },
459 )
460 .size_full()
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::content::transport::TrackStep;
467
468 #[test]
469 fn every_control_but_a_scrub_preview_is_a_command() {
470 assert_eq!(
471 command_for(&TransportEvent::PlayRequested),
472 Some(MediaCommand::Play)
473 );
474 assert_eq!(
475 command_for(&TransportEvent::SeekRequested(12.0)),
476 Some(MediaCommand::Seek(12.0))
477 );
478 assert_eq!(
479 command_for(&TransportEvent::Stepped(TrackStep::Next)),
480 Some(MediaCommand::Step(TrackStep::Next))
481 );
482 assert_eq!(
483 command_for(&TransportEvent::SeekPreview(12.0)),
484 None,
485 "a preview is where the pointer is, not where the head went"
486 );
487 }
488
489 #[test]
490 fn a_player_with_no_transport_is_not_a_player_holding_nothing() {
491 assert_ne!(NO_TRANSPORT, MediaAvailability::Idle.name());
492 }
493}