Skip to main content

gpui_kit/media/
video_player.rs

1//! A player for one piece of video: a frame surface, and the same transport.
2//!
3//! # What this component will not do
4//!
5//! **It does not decode a frame.** GPUI draws images, and on macOS composites
6//! a `CVPixelBuffer` through its surface element, but nothing in it decodes a
7//! video stream. So the picture is an element the host hands back per frame
8//! through [`VideoPlayer::frame`] — a `gpui::surface`, a `gpui::img`, or
9//! anything else it can build — and a host that hands back nothing has said
10//! so.
11//!
12//! **It does not use a poster as a frame.** A poster is one still, and a still
13//! standing in for playback is the most convincing possible lie about a player
14//! that is not playing. When the surface is showing a poster it publishes
15//! `poster`, and the reason no frames are arriving is drawn over it.
16//!
17//! **It never draws a progress bar over a picture that is not moving.** The
18//! bar is the transport's snapshot, and a surface with no transport gets no
19//! bar at all.
20//!
21//! **It states its aspect ratio rather than distorting the picture.** The
22//! frame is an [`AspectRatio`], so a 16∶9 stream
23//! in a 4∶3 slot is letterboxed by the layout instead of stretched by it.
24
25use std::rc::Rc;
26
27use gpui::{
28    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
29    Styled, Window, div, prelude::FluentBuilder,
30};
31use gpui_kit_semantics::{NodeSpec, Role, Semantic};
32use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TypeScale};
33
34use crate::content::transport::{TransportBar, TransportDuration};
35use crate::display::badge::Badge;
36use crate::foundation::{Disableable, Ident, StyledExt, text};
37use crate::layout::{AspectFit, AspectRatio};
38use crate::media::audio_player::{command_for, unready};
39use crate::media::transport::{MediaAvailability, MediaEvent, MediaSnapshot, MediaTransport};
40use crate::media::{NoticePlace, notice_at};
41use crate::strings::{ActiveStrings, StringKey};
42
43/// The shape a video slot takes when the caller states none.
44const DEFAULT_RATIO: f32 = 16.0 / 9.0;
45
46type EventHandler = Rc<dyn Fn(&MediaEvent, &mut Window, &mut App)>;
47type FrameSupplier = Rc<dyn Fn(&mut Window, &mut App) -> Option<AnyElement>>;
48
49/// What is in the frame, as the surface publishes it.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum SurfaceContent {
52    /// A frame the host supplied for this moment.
53    Frame,
54    /// A still, with the reason there is no moving picture over it.
55    Poster,
56    /// Neither, and the reason.
57    Nothing,
58}
59
60impl SurfaceContent {
61    fn name(self) -> &'static str {
62        match self {
63            Self::Frame => "frame",
64            Self::Poster => "poster",
65            Self::Nothing => "none",
66        }
67    }
68}
69
70/// A player for one piece of video.
71#[derive(IntoElement)]
72pub struct VideoPlayer {
73    ident: Ident,
74    title: Option<SharedString>,
75    transport: Option<Rc<dyn MediaTransport>>,
76    frame: Option<FrameSupplier>,
77    poster: Option<FrameSupplier>,
78    ratio: f32,
79    elapsed: Option<SharedString>,
80    remaining: Option<SharedString>,
81    step: Option<f32>,
82    speeds: Vec<f32>,
83    disabled: bool,
84    on_event: Option<EventHandler>,
85}
86
87impl std::fmt::Debug for VideoPlayer {
88    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        formatter
90            .debug_struct("VideoPlayer")
91            .field("ident", &self.ident)
92            .field("title", &self.title)
93            .field("transport", &self.transport)
94            .field("ratio", &self.ratio)
95            .field("has_frames", &self.frame.is_some())
96            .field("has_poster", &self.poster.is_some())
97            .field("disabled", &self.disabled)
98            .field("has_handler", &self.on_event.is_some())
99            .finish()
100    }
101}
102
103impl VideoPlayer {
104    /// A player with no transport and no frames, which is a player that says
105    /// there is no playback here.
106    pub fn new(ident: impl Into<Ident>) -> Self {
107        Self {
108            ident: ident.into(),
109            title: None,
110            transport: None,
111            frame: None,
112            poster: None,
113            ratio: DEFAULT_RATIO,
114            elapsed: None,
115            remaining: None,
116            step: None,
117            speeds: Vec::new(),
118            disabled: false,
119            on_event: None,
120        }
121    }
122
123    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
124        self.title = Some(title.into());
125        self
126    }
127
128    /// The player behind the controls.
129    pub fn transport(mut self, transport: Rc<dyn MediaTransport>) -> Self {
130        self.transport = Some(transport);
131        self
132    }
133
134    /// Supplies the element that draws the current frame.
135    ///
136    /// Answering `None` is a statement, not a gap: the surface falls back to
137    /// the poster and says why the picture is not moving.
138    pub fn frame(
139        mut self,
140        supplier: impl Fn(&mut Window, &mut App) -> Option<AnyElement> + 'static,
141    ) -> Self {
142        self.frame = Some(Rc::new(supplier));
143        self
144    }
145
146    /// Supplies the still shown when there are no frames.
147    pub fn poster(
148        mut self,
149        supplier: impl Fn(&mut Window, &mut App) -> Option<AnyElement> + 'static,
150    ) -> Self {
151        self.poster = Some(Rc::new(supplier));
152        self
153    }
154
155    /// The picture's own shape, width divided by height.
156    pub fn ratio(mut self, ratio: f32) -> Self {
157        if ratio > 0.0 {
158            self.ratio = ratio;
159        }
160        self
161    }
162
163    /// The picture's shape as the two numbers the caller already has.
164    pub fn ratio_of(self, width: f32, height: f32) -> Self {
165        self.ratio(if height == 0.0 {
166            DEFAULT_RATIO
167        } else {
168            width / height
169        })
170    }
171
172    /// How far in the video is, in the host's own words.
173    pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
174        self.elapsed = Some(elapsed.into());
175        self
176    }
177
178    /// How much is left, in the host's own words.
179    pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
180        self.remaining = Some(remaining.into());
181        self
182    }
183
184    /// How far one arrow key jumps, in seconds.
185    pub fn step_seconds(mut self, seconds: f32) -> Self {
186        self.step = Some(seconds.max(0.0));
187        self
188    }
189
190    /// The speeds the transport offers. Offering none leaves the control out.
191    pub fn speeds(mut self, speeds: impl IntoIterator<Item = f32>) -> Self {
192        self.speeds = speeds.into_iter().filter(|speed| *speed > 0.0).collect();
193        self
194    }
195
196    pub fn on_event(
197        mut self,
198        handler: impl Fn(&MediaEvent, &mut Window, &mut App) + 'static,
199    ) -> Self {
200        self.on_event = Some(Rc::new(handler));
201        self
202    }
203}
204
205impl Disableable for VideoPlayer {
206    fn disabled(mut self, disabled: bool) -> Self {
207        self.disabled = disabled;
208        self
209    }
210}
211
212impl RenderOnce for VideoPlayer {
213    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
214        let theme = cx.theme().clone();
215        let ident = self.ident.clone();
216        let strings = cx.strings().clone();
217
218        let snapshot = self
219            .transport
220            .as_ref()
221            .map(|transport| transport.snapshot());
222        let origin = self.transport.as_ref().map(|transport| transport.origin());
223        let availability = snapshot.as_ref().map(|snapshot| &snapshot.availability);
224        let state = match availability {
225            Some(availability) => availability.name(),
226            None => "no-transport",
227        };
228
229        // Frames are only asked for while the transport says it is holding
230        // media. Asking a host for a picture of something it has told us it
231        // cannot open would invite it to answer with one.
232        let supplied = availability
233            .filter(|availability| availability.is_ready())
234            .and(self.frame.as_ref())
235            .and_then(|supplier| supplier(window, cx));
236        let poster = self
237            .poster
238            .as_ref()
239            .and_then(|supplier| supplier(window, cx));
240
241        let content = match (&supplied, &poster) {
242            (Some(_), _) => SurfaceContent::Frame,
243            (None, Some(_)) => SurfaceContent::Poster,
244            (None, None) => SurfaceContent::Nothing,
245        };
246
247        let mut picture = div()
248            .relative()
249            .size_full()
250            .overflow_hidden()
251            .radius(&theme, Radius::Card)
252            .frame(&theme, Surface::Sunken, Elevation::Flat);
253        if let Some(element) = supplied {
254            picture = picture.child(div().absolute().inset_0().child(element));
255        } else if let Some(element) = poster {
256            picture = picture.child(div().absolute().inset_0().child(element));
257        }
258
259        // Whatever is behind it, the surface says what is missing whenever
260        // there is no live frame in it.
261        if !matches!(content, SurfaceContent::Frame) {
262            let place = match content {
263                SurfaceContent::Poster => NoticePlace::Foot,
264                _ => NoticePlace::Middle,
265            };
266            picture = picture.child(match (&snapshot, &self.transport) {
267                (Some(snapshot), _) if snapshot.availability.is_ready() => notice_at(
268                    &theme,
269                    theme.colors.warning,
270                    strings.text(StringKey::VideoNoFrames),
271                    strings.text(StringKey::VideoNoFramesDetail),
272                    place,
273                ),
274                (Some(snapshot), _) => unready(
275                    &theme,
276                    &strings,
277                    &snapshot.availability,
278                    self.title.clone(),
279                    place,
280                ),
281                (None, _) => notice_at(
282                    &theme,
283                    theme.colors.warning,
284                    strings.text(StringKey::MediaNoTransport),
285                    strings.text(StringKey::MediaNoTransportDetail),
286                    place,
287                ),
288            });
289        }
290
291        let surface = AspectRatio::new(ident.child("aspect"), self.ratio)
292            .fit(AspectFit::Width)
293            .child(
294                picture.semantic_in(
295                    cx,
296                    NodeSpec::new(ident.child("surface").semantic_id(), Role::Image)
297                        .parent(ident.semantic_id())
298                        .busy(matches!(availability, Some(MediaAvailability::Loading)))
299                        .invalid(matches!(availability, Some(MediaAvailability::Failed(_))))
300                        .value(content.name()),
301                ),
302            );
303
304        let titles = div()
305            .row()
306            .w_full()
307            .items_center()
308            .justify_between()
309            .gap_token(&theme, Space::Sm)
310            .children(
311                self.title
312                    .clone()
313                    .map(|title| text(&theme, TypeScale::Subtitle, title)),
314            )
315            .children(
316                origin
317                    .filter(|origin| origin.is_fixture())
318                    .map(|_| Badge::new(strings.text(StringKey::MediaFixture)).warning()),
319            );
320        let titles = match origin {
321            Some(origin) => titles
322                .semantic_in(
323                    cx,
324                    NodeSpec::new(ident.child("origin").semantic_id(), Role::Status)
325                        .parent(ident.semantic_id())
326                        .value(origin.name()),
327                )
328                .into_any_element(),
329            None => titles.into_any_element(),
330        };
331
332        let actionable = !self.disabled
333            && snapshot
334                .as_ref()
335                .is_some_and(|snapshot| snapshot.availability.is_ready());
336
337        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Group)
338            .disabled(self.disabled)
339            .busy(matches!(availability, Some(MediaAvailability::Loading)))
340            .invalid(matches!(availability, Some(MediaAvailability::Failed(_))))
341            .value(state);
342        if let Some(title) = self.title.clone() {
343            spec = spec.text(title);
344        }
345
346        let controls = match (&snapshot, &self.transport) {
347            (Some(snapshot), Some(transport)) => Some(bar(
348                &ident,
349                &self,
350                snapshot,
351                Rc::clone(transport),
352                actionable,
353            )),
354            // No transport is no controls. A disabled bar would still say
355            // playback exists here, and it does not.
356            _ => None,
357        };
358
359        div()
360            .id(ident.element_id())
361            .column()
362            .w_full()
363            .gap_token(&theme, Space::Sm)
364            .when(self.disabled, |element| {
365                element.opacity(theme.opacity.disabled)
366            })
367            .child(titles)
368            .child(surface)
369            .children(controls)
370            .semantic_in(cx, spec)
371    }
372}
373
374/// The transport bar, wired to ask the transport and report the answer.
375fn bar(
376    ident: &Ident,
377    player: &VideoPlayer,
378    snapshot: &MediaSnapshot,
379    transport: Rc<dyn MediaTransport>,
380    actionable: bool,
381) -> TransportBar {
382    let mut bar = TransportBar::new(ident.child("transport"))
383        .state(snapshot.state)
384        .position(snapshot.position)
385        .volume(snapshot.volume)
386        .muted(snapshot.muted)
387        .buffered(snapshot.buffered.iter().copied())
388        .disabled(!actionable);
389    bar = match snapshot.duration {
390        TransportDuration::Known(seconds) => bar.duration(seconds),
391        TransportDuration::Unknown => bar.unknown_duration(),
392    };
393    if let Some(elapsed) = player.elapsed.clone() {
394        bar = bar.elapsed(elapsed);
395    }
396    if let Some(remaining) = player.remaining.clone() {
397        bar = bar.remaining(remaining);
398    }
399    if let Some(step) = player.step {
400        bar = bar.step_seconds(step);
401    }
402    if !player.speeds.is_empty() {
403        bar = bar.speeds(player.speeds.iter().copied(), snapshot.speed);
404    }
405    if actionable {
406        let handler = player.on_event.clone();
407        bar = bar.on_event(move |event, window, cx| {
408            let Some(command) = command_for(event) else {
409                return;
410            };
411            let outcome = transport.apply(command);
412            if let Some(handler) = &handler {
413                handler(&MediaEvent::of(command, outcome), window, cx);
414            }
415            window.refresh();
416        });
417    }
418    bar
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn a_poster_is_published_as_a_poster_and_never_as_a_frame() {
427        assert_eq!(SurfaceContent::Frame.name(), "frame");
428        assert_eq!(SurfaceContent::Poster.name(), "poster");
429        assert_eq!(SurfaceContent::Nothing.name(), "none");
430    }
431
432    #[test]
433    fn a_shape_nobody_stated_is_the_one_a_video_slot_usually_is() {
434        let player = VideoPlayer::new("clip");
435        assert!((player.ratio - DEFAULT_RATIO).abs() < f32::EPSILON);
436        assert!((VideoPlayer::new("clip").ratio_of(4.0, 3.0).ratio - 4.0 / 3.0).abs() < 0.001);
437        assert!(
438            (VideoPlayer::new("clip").ratio(-1.0).ratio - DEFAULT_RATIO).abs() < f32::EPSILON,
439            "a ratio that is not a ratio is refused rather than divided by"
440        );
441    }
442}