Skip to main content

gpui_kit/media/
transport.rs

1//! The seam between a player and the surfaces that show one.
2//!
3//! [`crate::content::TransportBar`] reports every control and applies none of
4//! them, which is right for a bar the host wires to its own player. A player
5//! *component* needs the other half: something it can ask what is true and
6//! ask to change it, without this crate holding a decoder, a clock, or a
7//! device.
8//!
9//! That something is [`MediaTransport`]. It is a trait rather than an
10//! implementation because there is no audio or video backend in GPUI at the
11//! pinned revision — no decoder, no output device, and no frame pump — so a
12//! component that claimed to play something would be claiming a capability
13//! nothing behind it has. The trait is the contract; an operating-system
14//! backend lands behind it additively, and the components above it do not
15//! change when it does.
16//!
17//! # What a transport promises
18//!
19//! - [`snapshot`](MediaTransport::snapshot) answers with what is true now. It
20//!   is read once per frame and must not block, allocate a decode, or start
21//!   one.
22//! - [`apply`](MediaTransport::apply) is asked for a change and answers
23//!   whether it took it. A refusal carries the backend's own sentence, and the
24//!   next snapshot still reports the state that actually holds — which is what
25//!   keeps a refused seek from moving the head on screen.
26//! - [`origin`](MediaTransport::origin) says whether the facts came from a
27//!   real player or from a fixture, and every component publishes it. A
28//!   fixture that could be mistaken for a player is the one failure this
29//!   module exists to prevent.
30//!
31//! # The fixture is not a player
32//!
33//! [`FixtureTransport`] decodes nothing, opens no device, and advances no
34//! clock: its position moves only when something asks it to seek. It exists so
35//! the scenes and the tests have deterministic media states, and it reports
36//! [`MediaOrigin::Fixture`] so that the surface above it says so on screen.
37
38use std::cell::RefCell;
39use std::rc::Rc;
40
41use gpui::SharedString;
42
43use crate::content::transport::{BufferedRange, TrackStep, TransportDuration, TransportState};
44
45/// Where a surface's playback facts came from.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum MediaOrigin {
48    /// A real player: a decoder, an output device, and a clock that advances
49    /// on its own.
50    Platform,
51    /// A deterministic stand-in. Nothing is decoded and no clock runs.
52    Fixture,
53}
54
55impl MediaOrigin {
56    /// The name a semantic node publishes, so a test reads where the facts
57    /// came from rather than which caption was drawn for them.
58    pub fn name(self) -> &'static str {
59        match self {
60            Self::Platform => "platform",
61            Self::Fixture => "fixture",
62        }
63    }
64
65    pub fn is_fixture(self) -> bool {
66        matches!(self, Self::Fixture)
67    }
68}
69
70/// Whether there is anything to play, and whether anything could play it.
71///
72/// This is [`crate::state::Loadable`]'s vocabulary with the one distinction a
73/// player needs and a list does not: media the host holds and nothing on this
74/// machine can decode is neither loading nor broken, and calling it either
75/// would send the reader looking for the wrong repair.
76#[derive(Debug, Clone, PartialEq, Eq, Default)]
77pub enum MediaAvailability {
78    /// Nothing has been handed to the transport yet.
79    #[default]
80    Idle,
81    /// The transport is opening the media.
82    Loading,
83    /// There is no backend on this machine that can play it, in the
84    /// backend's own words.
85    NoBackend(SharedString),
86    /// The media was opened and could not be read, in the backend's words.
87    Failed(SharedString),
88    /// The transport holds the media and can move it.
89    Ready,
90}
91
92impl MediaAvailability {
93    /// The name a semantic node publishes.
94    pub fn name(&self) -> &'static str {
95        match self {
96            Self::Idle => "idle",
97            Self::Loading => "loading",
98            Self::NoBackend(_) => "no-backend",
99            Self::Failed(_) => "failed",
100            Self::Ready => "ready",
101        }
102    }
103
104    pub fn is_ready(&self) -> bool {
105        matches!(self, Self::Ready)
106    }
107
108    /// The backend's own sentence, when it gave one.
109    pub fn reason(&self) -> Option<SharedString> {
110        match self {
111            Self::NoBackend(reason) | Self::Failed(reason) => Some(reason.clone()),
112            _ => None,
113        }
114    }
115}
116
117/// Everything a player surface draws, as the transport reports it.
118#[derive(Debug, Clone, PartialEq)]
119pub struct MediaSnapshot {
120    pub availability: MediaAvailability,
121    pub state: TransportState,
122    /// Where the head is, in seconds.
123    pub position: f32,
124    pub duration: TransportDuration,
125    pub volume: f32,
126    pub muted: bool,
127    pub speed: f32,
128    /// The spans the backend already holds. An empty list draws no buffer,
129    /// rather than a full one.
130    pub buffered: Vec<BufferedRange>,
131}
132
133impl Default for MediaSnapshot {
134    fn default() -> Self {
135        Self {
136            availability: MediaAvailability::default(),
137            state: TransportState::Paused,
138            position: 0.0,
139            duration: TransportDuration::Unknown,
140            volume: 1.0,
141            muted: false,
142            speed: 1.0,
143            buffered: Vec::new(),
144        }
145    }
146}
147
148/// What a surface asks a transport for. It decides none of it.
149#[derive(Debug, Clone, Copy, PartialEq)]
150pub enum MediaCommand {
151    Play,
152    Pause,
153    /// Go to this many seconds.
154    Seek(f32),
155    SetVolume(f32),
156    ToggleMute,
157    SetSpeed(f32),
158    Step(TrackStep),
159}
160
161impl MediaCommand {
162    /// The name a semantic node and an event log use.
163    pub fn name(self) -> &'static str {
164        match self {
165            Self::Play => "play",
166            Self::Pause => "pause",
167            Self::Seek(_) => "seek",
168            Self::SetVolume(_) => "volume",
169            Self::ToggleMute => "mute",
170            Self::SetSpeed(_) => "speed",
171            Self::Step(_) => "step",
172        }
173    }
174}
175
176/// What the transport did with a command.
177#[derive(Debug, Clone, PartialEq)]
178pub enum MediaOutcome {
179    /// The transport took it. The next snapshot says what that produced;
180    /// nothing here promises the change is already visible.
181    Applied,
182    /// The transport declined, in its own words.
183    Refused(SharedString),
184    /// This backend does not implement this command at all. It is not a
185    /// failure and not a refusal, and a surface may drop the control instead
186    /// of offering one that never works.
187    Unsupported,
188}
189
190impl MediaOutcome {
191    pub fn name(&self) -> &'static str {
192        match self {
193            Self::Applied => "applied",
194            Self::Refused(_) => "refused",
195            Self::Unsupported => "unsupported",
196        }
197    }
198}
199
200/// A player, seen from a component that will never be one.
201///
202/// Implementations answer through `&self` and hold whatever interior
203/// mutability they need, because a surface holds an [`Rc`] of one and is
204/// rebuilt every frame.
205pub trait MediaTransport: std::fmt::Debug {
206    /// Whether these facts come from a real player or from a fixture.
207    fn origin(&self) -> MediaOrigin;
208
209    /// What is true now. Called once per frame; it must not block.
210    fn snapshot(&self) -> MediaSnapshot;
211
212    /// Asks for a change, and answers what became of the request.
213    fn apply(&self, command: MediaCommand) -> MediaOutcome;
214}
215
216/// What a player surface reports. It applies none of it: the transport
217/// already did or already refused, and this says which.
218#[derive(Debug, Clone, PartialEq)]
219pub enum MediaEvent {
220    /// The transport took the command.
221    Applied(MediaCommand),
222    /// The transport declined, and this is why.
223    Refused(MediaCommand, SharedString),
224    /// The transport does not implement the command.
225    Unsupported(MediaCommand),
226}
227
228impl MediaEvent {
229    /// Pairs a command with what the transport answered.
230    pub fn of(command: MediaCommand, outcome: MediaOutcome) -> Self {
231        match outcome {
232            MediaOutcome::Applied => Self::Applied(command),
233            MediaOutcome::Refused(reason) => Self::Refused(command, reason),
234            MediaOutcome::Unsupported => Self::Unsupported(command),
235        }
236    }
237
238    pub fn command(&self) -> MediaCommand {
239        match self {
240            Self::Applied(command) | Self::Refused(command, _) | Self::Unsupported(command) => {
241                *command
242            }
243        }
244    }
245}
246
247/// A transport that plays nothing, on purpose.
248///
249/// It holds a snapshot the caller wrote and moves it only when a command asks
250/// it to: no clock runs, so the same scene renders the same pixels on every
251/// machine and in any order, which is what the visual gate needs. It records
252/// every command so a test can assert what a control asked for.
253///
254/// It reports [`MediaOrigin::Fixture`], and every surface in this module
255/// publishes and draws that, so a fixture on screen is never mistaken for a
256/// player.
257pub struct FixtureTransport {
258    snapshot: RefCell<MediaSnapshot>,
259    commands: RefCell<Vec<MediaCommand>>,
260    /// Set when the fixture is standing in for a backend that says no.
261    refusal: Option<SharedString>,
262    unsupported: Vec<&'static str>,
263}
264
265impl std::fmt::Debug for FixtureTransport {
266    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        formatter
268            .debug_struct("FixtureTransport")
269            .field("snapshot", &self.snapshot.borrow())
270            .field("commands", &self.commands.borrow().len())
271            .field("refuses", &self.refusal.is_some())
272            .finish()
273    }
274}
275
276impl Default for FixtureTransport {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282impl FixtureTransport {
283    /// A fixture holding nothing, which is [`MediaAvailability::Idle`].
284    pub fn new() -> Self {
285        Self {
286            snapshot: RefCell::new(MediaSnapshot::default()),
287            commands: RefCell::new(Vec::new()),
288            refusal: None,
289            unsupported: Vec::new(),
290        }
291    }
292
293    /// A fixture that holds media of a known length and is ready to move it.
294    pub fn ready(duration: f32) -> Self {
295        let mut fixture = Self::new();
296        {
297            let snapshot = fixture.snapshot.get_mut();
298            snapshot.availability = MediaAvailability::Ready;
299            snapshot.duration = TransportDuration::Known(duration.max(0.0));
300        }
301        fixture
302    }
303
304    /// A fixture standing in for a live stream: ready, and no total.
305    pub fn live() -> Self {
306        let mut fixture = Self::new();
307        {
308            let snapshot = fixture.snapshot.get_mut();
309            snapshot.availability = MediaAvailability::Ready;
310            snapshot.duration = TransportDuration::Unknown;
311        }
312        fixture
313    }
314
315    pub fn state(mut self, state: TransportState) -> Self {
316        self.snapshot.get_mut().state = state;
317        self
318    }
319
320    /// Where the head stands. It stays there: nothing here advances it.
321    pub fn position(mut self, seconds: f32) -> Self {
322        self.snapshot.get_mut().position = seconds.max(0.0);
323        self
324    }
325
326    pub fn volume(mut self, volume: f32) -> Self {
327        self.snapshot.get_mut().volume = volume.clamp(0.0, 1.0);
328        self
329    }
330
331    pub fn muted(mut self, muted: bool) -> Self {
332        self.snapshot.get_mut().muted = muted;
333        self
334    }
335
336    pub fn speed(mut self, speed: f32) -> Self {
337        self.snapshot.get_mut().speed = speed.max(f32::EPSILON);
338        self
339    }
340
341    pub fn buffered(mut self, ranges: impl IntoIterator<Item = BufferedRange>) -> Self {
342        self.snapshot.get_mut().buffered = ranges.into_iter().collect();
343        self
344    }
345
346    pub fn loading(mut self) -> Self {
347        self.snapshot.get_mut().availability = MediaAvailability::Loading;
348        self
349    }
350
351    /// Stands in for a machine with no backend that can play this.
352    pub fn no_backend(mut self, reason: impl Into<SharedString>) -> Self {
353        self.snapshot.get_mut().availability = MediaAvailability::NoBackend(reason.into());
354        self
355    }
356
357    /// Stands in for media that was opened and could not be read.
358    pub fn failed(mut self, reason: impl Into<SharedString>) -> Self {
359        self.snapshot.get_mut().availability = MediaAvailability::Failed(reason.into());
360        self
361    }
362
363    /// Makes every command answer [`MediaOutcome::Refused`], which is how a
364    /// test proves a refused control changes nothing on screen.
365    pub fn refusing(mut self, reason: impl Into<SharedString>) -> Self {
366        self.refusal = Some(reason.into());
367        self
368    }
369
370    /// Names commands this fixture does not implement, by
371    /// [`MediaCommand::name`].
372    pub fn unsupported(mut self, commands: impl IntoIterator<Item = &'static str>) -> Self {
373        self.unsupported = commands.into_iter().collect();
374        self
375    }
376
377    /// Every command asked of it, in order.
378    pub fn commands(&self) -> Vec<MediaCommand> {
379        self.commands.borrow().clone()
380    }
381
382    /// The fixture as a surface takes it.
383    pub fn shared(self) -> Rc<dyn MediaTransport> {
384        Rc::new(self)
385    }
386}
387
388impl MediaTransport for FixtureTransport {
389    fn origin(&self) -> MediaOrigin {
390        MediaOrigin::Fixture
391    }
392
393    fn snapshot(&self) -> MediaSnapshot {
394        self.snapshot.borrow().clone()
395    }
396
397    fn apply(&self, command: MediaCommand) -> MediaOutcome {
398        self.commands.borrow_mut().push(command);
399        if self.unsupported.contains(&command.name()) {
400            return MediaOutcome::Unsupported;
401        }
402        if let Some(reason) = &self.refusal {
403            return MediaOutcome::Refused(reason.clone());
404        }
405        let mut snapshot = self.snapshot.borrow_mut();
406        // A fixture that is not holding media cannot be moved by a command
407        // either: taking one would put it in a state its availability denies.
408        if !snapshot.availability.is_ready() {
409            return MediaOutcome::Unsupported;
410        }
411        match command {
412            MediaCommand::Play => snapshot.state = TransportState::Playing,
413            MediaCommand::Pause => snapshot.state = TransportState::Paused,
414            MediaCommand::Seek(seconds) => {
415                let clamped = match snapshot.duration.seconds() {
416                    Some(total) => seconds.clamp(0.0, total),
417                    None => seconds.max(0.0),
418                };
419                snapshot.position = clamped;
420            }
421            MediaCommand::SetVolume(volume) => snapshot.volume = volume.clamp(0.0, 1.0),
422            MediaCommand::ToggleMute => snapshot.muted = !snapshot.muted,
423            MediaCommand::SetSpeed(speed) => snapshot.speed = speed.max(f32::EPSILON),
424            // Which media comes next is a playlist, and a fixture holds one
425            // piece of media and no playlist.
426            MediaCommand::Step(_) => return MediaOutcome::Unsupported,
427        }
428        MediaOutcome::Applied
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn a_fixture_never_advances_its_own_head() {
438        let fixture = FixtureTransport::ready(120.0).position(30.0);
439        assert_eq!(fixture.apply(MediaCommand::Play), MediaOutcome::Applied);
440        assert_eq!(fixture.snapshot().state, TransportState::Playing);
441        assert_eq!(
442            fixture.snapshot().position,
443            30.0,
444            "playing a fixture decodes nothing, so nothing moves"
445        );
446    }
447
448    #[test]
449    fn a_seek_stops_at_a_known_end_and_only_at_zero_without_one() {
450        let bounded = FixtureTransport::ready(120.0);
451        bounded.apply(MediaCommand::Seek(500.0));
452        assert_eq!(bounded.snapshot().position, 120.0);
453
454        let live = FixtureTransport::live();
455        live.apply(MediaCommand::Seek(-5.0));
456        assert_eq!(live.snapshot().position, 0.0);
457        live.apply(MediaCommand::Seek(500.0));
458        assert_eq!(live.snapshot().position, 500.0);
459    }
460
461    #[test]
462    fn a_refusing_transport_changes_nothing_and_says_why() {
463        let fixture = FixtureTransport::ready(120.0).refusing("The device is in use.");
464        let outcome = fixture.apply(MediaCommand::Play);
465        assert_eq!(
466            outcome,
467            MediaOutcome::Refused(SharedString::from("The device is in use."))
468        );
469        assert_eq!(
470            fixture.snapshot().state,
471            TransportState::Paused,
472            "a refused command must leave the state that still holds"
473        );
474        assert_eq!(fixture.commands(), vec![MediaCommand::Play]);
475    }
476
477    #[test]
478    fn a_transport_holding_nothing_takes_no_command() {
479        let idle = FixtureTransport::new();
480        assert_eq!(idle.apply(MediaCommand::Play), MediaOutcome::Unsupported);
481        assert_eq!(idle.snapshot().state, TransportState::Paused);
482
483        let absent = FixtureTransport::new().no_backend("No decoder for AV1 on this machine.");
484        assert_eq!(absent.apply(MediaCommand::Play), MediaOutcome::Unsupported);
485        assert_eq!(
486            absent.snapshot().availability.name(),
487            "no-backend",
488            "a machine that cannot play it is not a machine that is loading it"
489        );
490    }
491
492    #[test]
493    fn an_event_carries_the_command_and_what_became_of_it() {
494        assert_eq!(
495            MediaEvent::of(MediaCommand::Pause, MediaOutcome::Applied),
496            MediaEvent::Applied(MediaCommand::Pause)
497        );
498        let refused = MediaEvent::of(
499            MediaCommand::Seek(4.0),
500            MediaOutcome::Refused(SharedString::from("Seeking a live stream is refused.")),
501        );
502        assert_eq!(refused.command(), MediaCommand::Seek(4.0));
503        assert!(matches!(refused, MediaEvent::Refused(_, _)));
504    }
505
506    #[test]
507    fn a_fixture_says_it_is_a_fixture() {
508        assert!(FixtureTransport::new().origin().is_fixture());
509        assert_eq!(MediaOrigin::Platform.name(), "platform");
510    }
511}