1use 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
50const TRACK_HEIGHT: f32 = 4.0;
53const KNOB: f32 = 11.0;
54const VOLUME_WIDTH: f32 = 96.0;
55
56const DEFAULT_STEP: f32 = 5.0;
58
59#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum TransportState {
85 Playing,
86 #[default]
87 Paused,
88 Buffering,
91}
92
93impl TransportState {
94 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#[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#[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#[derive(Debug, Clone, PartialEq)]
145pub enum TransportEvent {
146 PlayRequested,
147 PauseRequested,
148 SeekPreview(f32),
153 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#[derive(Debug, Default)]
170struct Scrubbing {
171 held: bool,
172}
173
174#[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 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 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 pub fn unknown_duration(mut self) -> Self {
258 self.duration = TransportDuration::Unknown;
259 self
260 }
261
262 pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
265 self.elapsed = Some(elapsed.into());
266 self
267 }
268
269 pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
273 self.remaining = Some(remaining.into());
274 self
275 }
276
277 pub fn buffered(mut self, ranges: impl IntoIterator<Item = BufferedRange>) -> Self {
279 self.buffered = ranges.into_iter().collect();
280 self
281 }
282
283 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 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 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
344fn 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
351fn 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
359fn 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 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 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 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 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 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 "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}