Skip to main content

gpui_kit/content/
transport.rs

1//! Playback controls for audio or video. Nothing here plays anything.
2//!
3//! # What this component will not do
4//!
5//! **It does not play, seek, or apply.** Every control reports a
6//! [`TransportEvent`] and stops. The position drawn is the one the caller says
7//! is true, so a host that refuses a seek keeps the position that still holds
8//! — the same rule the slider, the sort, and the selection keep.
9//!
10//! **It does not format a time.** The elapsed and remaining readouts are
11//! strings the host already wrote, exactly as [`crate::display::timeline::Timeline`] and
12//! [`crate::content::MessageList`] take theirs, because turning a number of
13//! seconds into words is locale work and whoever owns the clock owns the
14//! wording. The numeric position is separate, and it is what drives the track.
15//!
16//! **It does not invent a duration.** A live stream has a position and no
17//! total, which is the state [`crate::navigation::PageTotal::Unknown`] already
18//! names for pages: the scrubber then shows elapsed, says the total is
19//! unknown, and draws no track fraction at all rather than a fraction of
20//! nothing.
21//!
22//! **It does not imply a buffer.** Buffered ranges are the host's, drawn
23//! distinctly from the played position; a host that supplies none gets no
24//! buffer drawn, not a full one.
25//!
26//! **It does not call waiting "paused".** A transport that is playing and
27//! stalled says it is waiting for data. A reader who is told it is paused
28//! would reach for the control that would actually stop it.
29
30use std::rc::Rc;
31
32use gpui::{
33    App, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
34    SharedString, Styled, Window, div, prelude::FluentBuilder, px, relative,
35};
36use gpui_kit_assets::Icon;
37use gpui_kit_semantics::{NodeSpec, Role, Semantic};
38use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
39
40use crate::controls::button::{Button, IconButton};
41use crate::controls::segmented::{Segment, SegmentedControl};
42use crate::controls::slider::Slider;
43use crate::display::badge::Tone;
44use crate::display::status::StatusLine;
45use crate::foundation::{Disableable, FocusRing, Ident, Selectable, Sizable, StyledExt};
46use crate::layout::measure;
47use crate::motion::{self, keyed};
48use crate::strings::{ActiveStrings, StringKey};
49
50/// How tall the scrubber's track is, and how wide the volume control is.
51/// Neither value repeats anywhere else.
52const TRACK_HEIGHT: f32 = 4.0;
53const KNOB: f32 = 11.0;
54const VOLUME_WIDTH: f32 = 96.0;
55
56/// How far one arrow key moves the position when the caller says nothing.
57const DEFAULT_STEP: f32 = 5.0;
58
59/// How long the media runs, as far as the host knows.
60///
61/// `Unknown` is a live stream: there is a position and there is no total, and
62/// a bar that drew a fraction anyway would be reporting a number nobody has.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub enum TransportDuration {
65    Known(f32),
66    Unknown,
67}
68
69impl TransportDuration {
70    pub fn is_known(self) -> bool {
71        matches!(self, Self::Known(_))
72    }
73
74    pub fn seconds(self) -> Option<f32> {
75        match self {
76            Self::Known(seconds) if seconds > 0.0 => Some(seconds),
77            _ => None,
78        }
79    }
80}
81
82/// What the transport is doing, as the host reports it.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum TransportState {
85    Playing,
86    #[default]
87    Paused,
88    /// Playing, and waiting for data. This is not paused, and it does not
89    /// offer the control that would resume, because nothing has stopped.
90    Buffering,
91}
92
93impl TransportState {
94    /// The name a semantic node publishes, so a test asserts the state rather
95    /// than the label drawn for it.
96    pub fn name(self) -> &'static str {
97        match self {
98            Self::Playing => "playing",
99            Self::Paused => "paused",
100            Self::Buffering => "buffering",
101        }
102    }
103
104    fn is_playing(self) -> bool {
105        matches!(self, Self::Playing | Self::Buffering)
106    }
107}
108
109/// One span of media the host already holds, in seconds.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct BufferedRange {
112    pub start: f32,
113    pub end: f32,
114}
115
116impl BufferedRange {
117    pub fn new(start: f32, end: f32) -> Self {
118        let (start, end) = if start <= end {
119            (start, end)
120        } else {
121            (end, start)
122        };
123        Self { start, end }
124    }
125}
126
127/// Which way a previous or next control steps.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum TrackStep {
130    Previous,
131    Next,
132}
133
134impl TrackStep {
135    pub fn name(self) -> &'static str {
136        match self {
137            Self::Previous => "previous",
138            Self::Next => "next",
139        }
140    }
141}
142
143/// What a transport reports. It applies none of it.
144#[derive(Debug, Clone, PartialEq)]
145pub enum TransportEvent {
146    PlayRequested,
147    PauseRequested,
148    /// Where the scrubber is while it is being dragged, in seconds.
149    ///
150    /// Reported on every move so a host can show a preview frame, and never
151    /// as a seek, so a host does not seek once per pixel.
152    SeekPreview(f32),
153    /// Where the reader let go, or where a key asked to go, in seconds.
154    SeekRequested(f32),
155    VolumeRequested(f32),
156    MuteToggled,
157    SpeedRequested(f32),
158    Stepped(TrackStep),
159}
160
161type EventHandler = Rc<dyn Fn(&TransportEvent, &mut Window, &mut App)>;
162
163/// Set by the scrubber's own pointer handlers and read by the next build.
164///
165/// A `RenderOnce` builder is rebuilt every frame, so the press that starts a
166/// scrub and the moves that continue it happen in different builds and need
167/// somewhere outside the frame to agree. It is the arrangement `SplitPane`
168/// uses for its divider, for the same reason.
169#[derive(Debug, Default)]
170struct Scrubbing {
171    held: bool,
172}
173
174/// A transport for one piece of media.
175#[derive(IntoElement)]
176pub struct TransportBar {
177    ident: Ident,
178    label: Option<SharedString>,
179    state: TransportState,
180    position: f32,
181    duration: TransportDuration,
182    elapsed: Option<SharedString>,
183    remaining: Option<SharedString>,
184    buffered: Vec<BufferedRange>,
185    volume: f32,
186    muted: bool,
187    speed: f32,
188    speeds: Vec<f32>,
189    step: f32,
190    has_previous: bool,
191    has_next: bool,
192    disabled: bool,
193    on_event: Option<EventHandler>,
194}
195
196impl std::fmt::Debug for TransportBar {
197    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        formatter
199            .debug_struct("TransportBar")
200            .field("ident", &self.ident)
201            .field("state", &self.state)
202            .field("position", &self.position)
203            .field("duration", &self.duration)
204            .field("buffered", &self.buffered.len())
205            .field("disabled", &self.disabled)
206            .field("has_handler", &self.on_event.is_some())
207            .finish()
208    }
209}
210
211impl TransportBar {
212    pub fn new(ident: impl Into<Ident>) -> Self {
213        Self {
214            ident: ident.into(),
215            label: None,
216            state: TransportState::default(),
217            position: 0.0,
218            duration: TransportDuration::Unknown,
219            elapsed: None,
220            remaining: None,
221            buffered: Vec::new(),
222            volume: 1.0,
223            muted: false,
224            speed: 1.0,
225            speeds: Vec::new(),
226            step: DEFAULT_STEP,
227            has_previous: false,
228            has_next: false,
229            disabled: false,
230            on_event: None,
231        }
232    }
233
234    /// What is playing, shown beside the controls.
235    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
236        self.label = Some(label.into());
237        self
238    }
239
240    pub fn state(mut self, state: TransportState) -> Self {
241        self.state = state;
242        self
243    }
244
245    /// Where the caller says the head is, in seconds.
246    pub fn position(mut self, seconds: f32) -> Self {
247        self.position = seconds.max(0.0);
248        self
249    }
250
251    pub fn duration(mut self, seconds: f32) -> Self {
252        self.duration = TransportDuration::Known(seconds.max(0.0));
253        self
254    }
255
256    /// Says there is a position and no total, for a live stream.
257    pub fn unknown_duration(mut self) -> Self {
258        self.duration = TransportDuration::Unknown;
259        self
260    }
261
262    /// How far in the media is, in the host's own words. This crate formats
263    /// no durations.
264    pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
265        self.elapsed = Some(elapsed.into());
266        self
267    }
268
269    /// How much is left, in the host's own words. Without one nothing is
270    /// shown, because the remainder is a duration and this crate computes
271    /// none.
272    pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
273        self.remaining = Some(remaining.into());
274        self
275    }
276
277    /// The spans the host already holds. Supplying none draws no buffer.
278    pub fn buffered(mut self, ranges: impl IntoIterator<Item = BufferedRange>) -> Self {
279        self.buffered = ranges.into_iter().collect();
280        self
281    }
282
283    /// Where the volume control sits, between zero and one.
284    pub fn volume(mut self, volume: f32) -> Self {
285        self.volume = volume.clamp(0.0, 1.0);
286        self
287    }
288
289    pub fn muted(mut self, muted: bool) -> Self {
290        self.muted = muted;
291        self
292    }
293
294    /// The speeds the host offers, and which of them holds. Offering none
295    /// leaves the control out entirely.
296    pub fn speeds(mut self, speeds: impl IntoIterator<Item = f32>, current: f32) -> Self {
297        self.speeds = speeds.into_iter().filter(|speed| *speed > 0.0).collect();
298        self.speed = current;
299        self
300    }
301
302    /// How far one arrow key jumps, in seconds.
303    ///
304    /// A caller input, because whether an arrow should move a second or a
305    /// minute is a judgement about the media, and this component knows
306    /// nothing about the media.
307    pub fn step_seconds(mut self, seconds: f32) -> Self {
308        self.step = seconds.max(0.0);
309        self
310    }
311
312    pub fn has_previous(mut self, has_previous: bool) -> Self {
313        self.has_previous = has_previous;
314        self
315    }
316
317    pub fn has_next(mut self, has_next: bool) -> Self {
318        self.has_next = has_next;
319        self
320    }
321
322    pub fn on_event(
323        mut self,
324        handler: impl Fn(&TransportEvent, &mut Window, &mut App) + 'static,
325    ) -> Self {
326        self.on_event = Some(Rc::new(handler));
327        self
328    }
329
330    fn fraction(&self) -> Option<f32> {
331        self.duration
332            .seconds()
333            .map(|total| (self.position / total).clamp(0.0, 1.0))
334    }
335}
336
337impl Disableable for TransportBar {
338    fn disabled(mut self, disabled: bool) -> Self {
339        self.disabled = disabled;
340        self
341    }
342}
343
344/// The share of a known duration one buffered range covers.
345fn buffered_span(range: BufferedRange, total: f32) -> (f32, f32) {
346    let start = (range.start / total).clamp(0.0, 1.0);
347    let end = (range.end / total).clamp(0.0, 1.0);
348    (start, (end - start).max(0.0))
349}
350
351/// How far into the media a pointer standing at `x` is asking for.
352fn seek_at(x: f32, left: f32, width: f32, total: f32) -> f32 {
353    if width <= 0.0 {
354        return 0.0;
355    }
356    (((x - left) / width).clamp(0.0, 1.0) * total).clamp(0.0, total)
357}
358
359/// An id-safe stem for a speed, so `1.5×` addresses as `speed-1-5`.
360fn speed_id(speed: f32) -> String {
361    format!("speed-{}", format!("{speed}").replace('.', "-"))
362}
363
364fn speed_label(speed: f32) -> String {
365    format!("{speed}×")
366}
367
368impl RenderOnce for TransportBar {
369    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
370        let theme = cx.theme().clone();
371        let ident = self.ident.clone();
372        let actionable = !self.disabled && self.on_event.is_some();
373        let handler = self.on_event.clone().filter(|_| actionable);
374        let report = {
375            let handler = handler.clone();
376            Rc::new(
377                move |event: TransportEvent, window: &mut Window, cx: &mut App| {
378                    if let Some(handler) = &handler {
379                        handler(&event, window, cx);
380                    }
381                },
382            )
383        };
384
385        let total = self.duration.seconds();
386        let fraction = self.fraction();
387        let scrubbing = keyed::slot::<Scrubbing>(&ident.child("scrubber").semantic_id(), cx);
388        let held = scrubbing.borrow().held;
389        // A head the pointer is holding has to be exactly under the pointer,
390        // so the spring is skipped for a move this bar caused itself.
391        let drawn = fraction.map(|fraction| {
392            motion::tracked_or_snap(
393                &ident.child("scrubber").semantic_id(),
394                fraction,
395                motion::tracking(&theme),
396                held,
397                window,
398                cx,
399            )
400        });
401
402        let measured = measure::cell(&ident.child("scrubber").semantic_id(), cx);
403        let scrubbable = actionable && total.is_some();
404
405        let mut track = div()
406            .id(ident.child("scrubber").element_id())
407            .relative()
408            .w_full()
409            .h(px(KNOB))
410            .flex()
411            .items_center()
412            .when(scrubbable, |element| element.cursor_pointer())
413            .child(
414                div()
415                    .absolute()
416                    .left_0()
417                    .right_0()
418                    .h(px(TRACK_HEIGHT))
419                    .rounded_full()
420                    .bg(theme.colors.hairline_strong),
421            );
422
423        // Buffered spans are the host's fact and are drawn as their own band,
424        // never as part of the played fill: a reader who cannot tell them
425        // apart has been told the media is further along than it is. A host
426        // that supplied none gets no band and no node, so "nothing buffered"
427        // is distinguishable from "buffered to the start".
428        if let (Some(total), false) = (total, self.buffered.is_empty()) {
429            let mut band = div().absolute().left_0().right_0().h(px(TRACK_HEIGHT));
430            for range in &self.buffered {
431                let (start, width) = buffered_span(*range, total);
432                if width <= 0.0 {
433                    continue;
434                }
435                band = band.child(
436                    div()
437                        .absolute()
438                        .left(relative(start))
439                        .w(relative(width))
440                        .h(px(TRACK_HEIGHT))
441                        .rounded_full()
442                        .bg(theme.colors.text_faint),
443                );
444            }
445            let furthest = self
446                .buffered
447                .iter()
448                .map(|range| buffered_span(*range, total))
449                .map(|(start, width)| start + width)
450                .fold(0.0_f32, f32::max);
451            // The node describes the buffered extent rather than the whole
452            // track, so a test reads how far the buffer reaches from its
453            // bounds as well as from its range.
454            track = track.child(
455                band.child(
456                    div()
457                        .absolute()
458                        .left_0()
459                        .w(relative(furthest.max(f32::EPSILON)))
460                        .h(px(TRACK_HEIGHT))
461                        .semantic_in(
462                            cx,
463                            NodeSpec::new(ident.child("buffered").semantic_id(), Role::Status)
464                                .parent(ident.semantic_id())
465                                .text(cx.strings().text(StringKey::TransportBuffered))
466                                .range(0.0, 1.0, furthest)
467                                .value(format!("{} ranges", self.buffered.len())),
468                        ),
469                ),
470            );
471        }
472
473        if let Some(drawn) = drawn {
474            track = track
475                .child(
476                    div()
477                        .absolute()
478                        .left_0()
479                        .w(relative(drawn))
480                        .h(px(TRACK_HEIGHT))
481                        .rounded_full()
482                        .bg(theme.colors.accent),
483                )
484                .child(
485                    div()
486                        .absolute()
487                        .left(relative(drawn))
488                        .ml(px(-KNOB / 2.0))
489                        .size(px(KNOB))
490                        .rounded_full()
491                        .bg(theme.colors.text)
492                        .border(px(theme.borders.hairline))
493                        .border_color(theme.colors.hairline_strong),
494                );
495        }
496
497        if let (true, Some(total)) = (scrubbable, total) {
498            let press = Rc::clone(&report);
499            let press_bounds = Rc::clone(&measured);
500            let press_held = Rc::clone(&scrubbing);
501            track = track.on_mouse_down(MouseButton::Left, move |event, window, cx| {
502                let bounds = press_bounds.get();
503                press_held.borrow_mut().held = true;
504                press(
505                    TransportEvent::SeekPreview(seek_from(event.position, bounds, total)),
506                    window,
507                    cx,
508                );
509            });
510        }
511
512        let readout = |text: SharedString, tone: gpui::Hsla| {
513            div()
514                .flex_none()
515                .type_scale(&theme, TypeScale::Caption)
516                .text_color(tone)
517                .child(text)
518        };
519
520        let elapsed = self
521            .elapsed
522            .clone()
523            .unwrap_or_else(|| cx.strings().text(StringKey::TransportTimeUnknown));
524        // With no total there is nothing to count down to, so the right-hand
525        // readout states the fact rather than a number.
526        let trailing = match (self.duration, self.remaining.clone()) {
527            (TransportDuration::Unknown, _) => {
528                Some(cx.strings().text(StringKey::TransportDurationUnknown))
529            }
530            (TransportDuration::Known(_), remaining) => remaining,
531        };
532
533        let scrubber_spec = match total {
534            Some(total) => NodeSpec::new(ident.child("scrubber").semantic_id(), Role::Slider)
535                .parent(ident.semantic_id())
536                .text(cx.strings().text(StringKey::TransportPosition))
537                .disabled(!scrubbable)
538                .range(0.0, total, self.position.clamp(0.0, total))
539                .value(elapsed.clone()),
540            None => NodeSpec::new(ident.child("scrubber").semantic_id(), Role::Status)
541                .parent(ident.semantic_id())
542                .text(cx.strings().text(StringKey::TransportDurationUnknown))
543                .value(elapsed.clone()),
544        };
545
546        let scrubber_row = div()
547            .row()
548            .w_full()
549            .gap_token(&theme, Space::Sm)
550            .child(readout(elapsed.clone(), theme.colors.text_muted))
551            .child(
552                div()
553                    .flex_1()
554                    .min_w_0()
555                    .on_children_prepainted({
556                        let measured = Rc::clone(&measured);
557                        move |bounds, window, _| {
558                            if let Some(first) = bounds.first() {
559                                measure::record(&measured, *first, window);
560                            }
561                        }
562                    })
563                    .child(track)
564                    .semantic_in(cx, scrubber_spec),
565            )
566            .children(trailing.map(|text| readout(text, theme.colors.text_faint)));
567
568        let playing = self.state.is_playing();
569        let play = {
570            let report = Rc::clone(&report);
571            let mut control = Button::new(ident.child(if playing { "pause" } else { "play" }))
572                .label(cx.strings().text(if playing {
573                    StringKey::TransportPause
574                } else {
575                    StringKey::TransportPlay
576                }))
577                .secondary()
578                .control_size(ControlSize::Sm)
579                .semantic_parent(ident.semantic_id())
580                .disabled(!actionable);
581            if actionable {
582                control = control.on_click(move |window, cx| {
583                    let event = if playing {
584                        TransportEvent::PauseRequested
585                    } else {
586                        TransportEvent::PlayRequested
587                    };
588                    report(event, window, cx);
589                });
590            }
591            control
592        };
593
594        let strings = cx.strings().clone();
595        let step_control =
596            |name: &'static str, glyph: Icon, key: StringKey, step: TrackStep, enabled: bool| {
597                let mut control = IconButton::new(ident.child(name), glyph, strings.text(key))
598                    .ghost()
599                    .control_size(ControlSize::Sm)
600                    .semantic_parent(ident.semantic_id())
601                    .disabled(!enabled || !actionable);
602                if enabled && actionable {
603                    let report = Rc::clone(&report);
604                    control = control.on_click(move |window, cx| {
605                        report(TransportEvent::Stepped(step), window, cx)
606                    });
607                }
608                control
609            };
610
611        let status = match self.state {
612            TransportState::Playing => (strings.text(StringKey::TransportPlaying), Tone::Success),
613            TransportState::Paused => (strings.text(StringKey::TransportPaused), Tone::Neutral),
614            TransportState::Buffering => {
615                (strings.text(StringKey::TransportBuffering), Tone::Warning)
616            }
617        };
618
619        let mute = {
620            let report = Rc::clone(&report);
621            let mut control = Button::new(ident.child("mute"))
622                .label(strings.text(if self.muted {
623                    StringKey::TransportUnmute
624                } else {
625                    StringKey::TransportMute
626                }))
627                .ghost()
628                .control_size(ControlSize::Sm)
629                .semantic_parent(ident.semantic_id())
630                .selected(self.muted)
631                .disabled(!actionable);
632            if actionable {
633                control = control
634                    .on_click(move |window, cx| report(TransportEvent::MuteToggled, window, cx));
635            }
636            control
637        };
638
639        let volume = {
640            let report = Rc::clone(&report);
641            let mut control = Slider::new(ident.child("volume"))
642                .label(strings.text(StringKey::TransportVolume))
643                .range(0.0, 1.0)
644                .value(self.volume)
645                .control_size(ControlSize::Sm)
646                .display(format!("{}%", (self.volume * 100.0).round() as i64))
647                .disabled(!actionable);
648            if actionable {
649                control = control.on_change(move |value, window, cx| {
650                    report(TransportEvent::VolumeRequested(value), window, cx)
651                });
652            }
653            div().flex_none().w(px(VOLUME_WIDTH)).child(control)
654        };
655
656        let speeds = (!self.speeds.is_empty()).then(|| {
657            let offered = self.speeds.clone();
658            let report = Rc::clone(&report);
659            let mut control = SegmentedControl::new(ident.child("speed"))
660                .control_size(ControlSize::Xs)
661                .segments(
662                    offered
663                        .iter()
664                        .map(|speed| Segment::new(speed_id(*speed), speed_label(*speed))),
665                )
666                .selected(speed_id(self.speed))
667                .disabled(!actionable);
668            if actionable {
669                control = control.on_select(move |id, window, cx| {
670                    let Some(speed) = offered
671                        .iter()
672                        .find(|speed| speed_id(**speed) == id.as_ref())
673                    else {
674                        return;
675                    };
676                    report(TransportEvent::SpeedRequested(*speed), window, cx);
677                });
678            }
679            control
680        });
681
682        let controls = div()
683            .row()
684            .w_full()
685            .flex_wrap()
686            .gap_token(&theme, Space::Sm)
687            .justify_between()
688            .child(
689                div()
690                    .row()
691                    .gap_token(&theme, Space::Xs)
692                    .child(step_control(
693                        "previous",
694                        Icon::AltArrowLeft,
695                        StringKey::TransportPreviousTrack,
696                        TrackStep::Previous,
697                        self.has_previous,
698                    ))
699                    .child(play)
700                    .child(step_control(
701                        "next",
702                        Icon::AltArrowRight,
703                        StringKey::TransportNextTrack,
704                        TrackStep::Next,
705                        self.has_next,
706                    ))
707                    .children(self.label.clone().map(|label| {
708                        div()
709                            .type_scale(&theme, TypeScale::Label)
710                            .text_color(theme.colors.text)
711                            .child(label)
712                    }))
713                    .child(StatusLine::new(status.0, status.1).id(ident.child("status"))),
714            )
715            .child(
716                div()
717                    .row()
718                    .gap_token(&theme, Space::Sm)
719                    .child(mute)
720                    .child(volume)
721                    .children(speeds),
722            );
723
724        let mut root = div()
725            .id(ident.element_id())
726            .column()
727            .w_full()
728            .gap_token(&theme, Space::Sm)
729            .when(self.disabled, |element| {
730                element.opacity(theme.opacity.disabled)
731            })
732            .when(actionable, |element| {
733                element.tab_index(0).focus_ring(&theme)
734            })
735            .child(controls)
736            .child(scrubber_row);
737
738        if actionable {
739            // A scrub is followed across the whole bar rather than the few
740            // pixels of the track, so letting go outside it still commits
741            // once instead of leaving the scrub running.
742            if let Some(total) = total {
743                let drag = Rc::clone(&report);
744                let drag_bounds = Rc::clone(&measured);
745                let drag_held = Rc::clone(&scrubbing);
746                root = root.on_mouse_move(move |event, window, cx| {
747                    if !drag_held.borrow().held {
748                        return;
749                    }
750                    if event.pressed_button != Some(MouseButton::Left) {
751                        drag_held.borrow_mut().held = false;
752                        return;
753                    }
754                    drag(
755                        TransportEvent::SeekPreview(seek_from(
756                            event.position,
757                            drag_bounds.get(),
758                            total,
759                        )),
760                        window,
761                        cx,
762                    );
763                });
764
765                let release = Rc::clone(&report);
766                let release_bounds = Rc::clone(&measured);
767                let release_held = Rc::clone(&scrubbing);
768                root = root.on_mouse_up(MouseButton::Left, move |event, window, cx| {
769                    if !std::mem::take(&mut release_held.borrow_mut().held) {
770                        return;
771                    }
772                    release(
773                        TransportEvent::SeekRequested(seek_from(
774                            event.position,
775                            release_bounds.get(),
776                            total,
777                        )),
778                        window,
779                        cx,
780                    );
781                });
782            }
783
784            let keys = Rc::clone(&report);
785            let (position, step, state) = (self.position, self.step, self.state);
786            root.interactivity().on_key_down(move |event, window, cx| {
787                let event = match event.keystroke.key.as_str() {
788                    "space" => {
789                        if state.is_playing() {
790                            TransportEvent::PauseRequested
791                        } else {
792                            TransportEvent::PlayRequested
793                        }
794                    }
795                    // The forward end of a stream nobody measured is the
796                    // host's to clamp, because only the host knows where the
797                    // live edge is.
798                    "left" => TransportEvent::SeekRequested(clamp_to(position - step, total)),
799                    "right" => TransportEvent::SeekRequested(clamp_to(position + step, total)),
800                    _ => return,
801                };
802                cx.stop_propagation();
803                keys(event, window, cx);
804            });
805        }
806
807        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Group)
808            .disabled(self.disabled)
809            .busy(matches!(self.state, TransportState::Buffering))
810            .value(self.state.name());
811        if let Some(label) = self.label.clone() {
812            spec = spec.text(label);
813        }
814
815        root.semantic_in(cx, spec)
816    }
817}
818
819fn seek_from(at: Point<Pixels>, bounds: gpui::Bounds<Pixels>, total: f32) -> f32 {
820    seek_at(
821        f32::from(at.x),
822        f32::from(bounds.left()),
823        f32::from(bounds.size.width),
824        total,
825    )
826}
827
828fn clamp_to(seconds: f32, total: Option<f32>) -> f32 {
829    match total {
830        Some(total) => seconds.clamp(0.0, total),
831        None => seconds.max(0.0),
832    }
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    #[test]
840    fn a_live_stream_has_a_position_and_no_fraction() {
841        let live = TransportBar::new("live").position(90.0).unknown_duration();
842        assert!(!live.duration.is_known());
843        assert_eq!(live.fraction(), None, "there is no total to be a part of");
844    }
845
846    #[test]
847    fn a_zero_duration_is_no_more_a_total_than_an_unknown_one() {
848        let empty = TransportBar::new("empty").duration(0.0).position(0.0);
849        assert_eq!(empty.duration.seconds(), None);
850        assert_eq!(empty.fraction(), None);
851    }
852
853    #[test]
854    fn a_known_duration_places_the_head_and_stops_at_the_end() {
855        let bar = TransportBar::new("clip").duration(200.0).position(50.0);
856        assert_eq!(bar.fraction(), Some(0.25));
857        let past = TransportBar::new("clip").duration(200.0).position(400.0);
858        assert_eq!(past.fraction(), Some(1.0));
859    }
860
861    #[test]
862    fn a_buffered_range_covers_its_own_share_and_no_more() {
863        assert_eq!(
864            buffered_span(BufferedRange::new(0.0, 50.0), 200.0),
865            (0.0, 0.25)
866        );
867        assert_eq!(
868            buffered_span(BufferedRange::new(100.0, 400.0), 200.0),
869            (0.5, 0.5)
870        );
871        assert_eq!(
872            BufferedRange::new(80.0, 20.0),
873            BufferedRange::new(20.0, 80.0),
874            "a reversed range is corrected rather than drawn backwards"
875        );
876    }
877
878    #[test]
879    fn a_pointer_asks_for_the_position_it_is_standing_over() {
880        assert_eq!(seek_at(100.0, 100.0, 400.0, 200.0), 0.0);
881        assert_eq!(seek_at(300.0, 100.0, 400.0, 200.0), 100.0);
882        assert_eq!(seek_at(900.0, 100.0, 400.0, 200.0), 200.0);
883    }
884
885    #[test]
886    fn an_unmeasured_track_asks_for_nothing() {
887        assert_eq!(seek_at(300.0, 100.0, 0.0, 200.0), 0.0);
888    }
889
890    #[test]
891    fn a_step_stops_at_a_known_end_and_only_at_zero_without_one() {
892        assert_eq!(clamp_to(-5.0, Some(200.0)), 0.0);
893        assert_eq!(clamp_to(400.0, Some(200.0)), 200.0);
894        assert_eq!(clamp_to(-5.0, None), 0.0);
895        assert_eq!(clamp_to(400.0, None), 400.0);
896    }
897
898    #[test]
899    fn buffering_is_playing_rather_than_paused() {
900        assert!(TransportState::Buffering.is_playing());
901        assert!(TransportState::Playing.is_playing());
902        assert!(!TransportState::Paused.is_playing());
903        assert_eq!(TransportState::Buffering.name(), "buffering");
904    }
905
906    #[test]
907    fn a_speed_addresses_by_its_value_rather_than_its_place() {
908        assert_eq!(speed_id(1.5), "speed-1-5");
909        assert_eq!(speed_id(2.0), "speed-2");
910        assert_eq!(speed_label(1.5), "1.5×");
911    }
912}