Skip to main content

gpui_kit/media/
audio_player.rs

1//! A player for one piece of audio, over a transport this crate does not own.
2//!
3//! # What this component will not do
4//!
5//! **It does not play anything.** Every control asks the
6//! [`MediaTransport`] and reports what the transport answered. A refused
7//! command leaves the state that still holds, because the next frame draws
8//! the transport's snapshot rather than what the control wished for.
9//!
10//! **It does not run a clock.** The position is the transport's, read once per
11//! frame. A player with no transport draws no scrubber at all: a track with a
12//! head at zero would say playback exists and has not started, and neither
13//! half of that is true.
14//!
15//! **It does not draw a waveform it did not measure.** Peaks are a caller
16//! input, the way an image's natural size is. A player given none shows the
17//! transport and nothing above it, rather than a decorative envelope that
18//! would read as the shape of this particular sound.
19//!
20//! **It does not format a time.** Elapsed and remaining are the host's own
21//! strings, exactly as [`TransportBar`] takes them.
22//!
23//! **It says where its facts came from.** A fixture transport is published as
24//! one and drawn as one.
25
26use 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
42/// How tall the peak band is, and how much space is left between two bars.
43/// Neither value repeats anywhere else.
44const PEAKS_HEIGHT: f32 = 56.0;
45const PEAK_GAP: f32 = 1.0;
46
47/// How tall the frame is when there is a sentence in it instead of a player.
48const NOTICE_HEIGHT: f32 = 96.0;
49
50/// The shortest bar a peak of nearly nothing still draws, so a quiet passage
51/// is visible as quiet rather than as absent.
52const PEAK_FLOOR: f32 = 0.06;
53
54type EventHandler = Rc<dyn Fn(&MediaEvent, &mut Window, &mut App)>;
55
56/// A player for one piece of audio.
57#[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    /// A player with no transport, which is a player that says so.
88    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    /// What is playing.
105    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
106        self.title = Some(title.into());
107        self
108    }
109
110    /// A second line under the title: a speaker, an artist, a source.
111    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
112        self.subtitle = Some(subtitle.into());
113        self
114    }
115
116    /// The player behind the controls.
117    ///
118    /// Without one there is no playback on this machine, and this component
119    /// says that rather than drawing controls that would do nothing.
120    pub fn transport(mut self, transport: Rc<dyn MediaTransport>) -> Self {
121        self.transport = Some(transport);
122        self
123    }
124
125    /// How far in the audio is, in the host's own words.
126    pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
127        self.elapsed = Some(elapsed.into());
128        self
129    }
130
131    /// How much is left, in the host's own words.
132    pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
133        self.remaining = Some(remaining.into());
134        self
135    }
136
137    /// The peak amplitude of each slice of the audio, between zero and one,
138    /// as somebody who read the samples measured it.
139    ///
140    /// Supplying none draws no waveform. There is no decoder here, so an
141    /// envelope this component invented would be a picture of nothing.
142    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    /// How far one arrow key jumps, in seconds.
148    pub fn step_seconds(mut self, seconds: f32) -> Self {
149        self.step = Some(seconds.max(0.0));
150        self
151    }
152
153    /// The speeds the transport offers. Offering none leaves the control out.
154    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        // Where the facts came from is published for every transport and drawn
203        // only for the one a reader could mistake for a player.
204        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
329/// The state name for a player nobody gave a transport.
330///
331/// It is distinct from `idle`: a transport holding nothing can be handed
332/// something, and a player with no transport cannot play anything at all.
333const NO_TRANSPORT: &str = "no-transport";
334
335/// The transport bar, wired to ask the transport and report the answer.
336fn 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            // The transport changed outside anything GPUI observes, so the
377            // frame that would draw the new snapshot has to be asked for.
378            window.refresh();
379        });
380    }
381    bar
382}
383
384/// What a control asked the transport for, if it asked for anything.
385///
386/// A scrub preview is not a command: it is where the pointer is while the head
387/// has not moved, and applying one would seek once per pixel.
388pub(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
401/// The sentence a player draws when there is nothing to move.
402pub(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    // A backend's own sentence is the detail whenever it gave one, so a
417    // refusal is shown as the refusal it is rather than as an absence.
418    let detail = availability.reason().unwrap_or_else(|| strings.text(key));
419    super::notice_at(theme, tint, headline, detail, place)
420}
421
422/// The measured envelope, with the part behind the head drawn as played.
423fn 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            // With no duration there is no fraction, so nothing is drawn as
441            // played rather than everything or nothing being guessed at.
442            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}