Skip to main content

guise/video/
view.rs

1//! `VideoView` — shows the newest frame the host gives it.
2//!
3//! A stateful entity because it holds the current texture. Push frames from the
4//! UI thread with [`push_frame`](VideoView::push_frame), or call
5//! [`feed`](VideoView::feed) once and give the returned [`VideoFeed`] to a
6//! decoder thread.
7//!
8//! Every frame is a new gpui image, and gpui keeps an image's atlas slot until
9//! told to drop it. The view retires each replaced frame and drops it at the
10//! top of the next render, so a stream doesn't fill the atlas.
11
12use std::sync::Arc;
13use std::time::Duration;
14
15use gpui::prelude::*;
16use gpui::{
17  canvas, div, px, size, Bounds, ContentMask, Context, Corners, EventEmitter, Hsla, IntoElement,
18  RenderImage, SharedString, Window,
19};
20
21use super::{fit_rect, VideoFeed, VideoFit, VideoFrame};
22use crate::devtools::Probed;
23use crate::theme::{theme, Size};
24
25/// How often the feed's poller looks for a new frame. Under a frame at 120 fps,
26/// so it never adds a visible frame of latency.
27const POLL: Duration = Duration::from_millis(8);
28
29/// Emitted when the picture's dimensions change (including the first frame).
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum VideoEvent {
32  Resized { width: u32, height: u32 },
33}
34
35/// Frames shown and frames a [`VideoFeed`] overwrote before they were shown.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub struct VideoStats {
38  pub shown: u64,
39  pub dropped: u64,
40}
41
42/// The video surface. Create with `cx.new(|cx| VideoView::new(cx))`.
43pub struct VideoView {
44  image: Option<Arc<RenderImage>>,
45  dims: Option<(u32, u32)>,
46  retired: Vec<Arc<RenderImage>>,
47  fit: VideoFit,
48  background: Option<Hsla>,
49  width: Option<f32>,
50  height: Option<f32>,
51  radius: Option<Size>,
52  placeholder: SharedString,
53  shown: u64,
54  feeds: Vec<VideoFeed>,
55}
56
57impl EventEmitter<VideoEvent> for VideoView {}
58
59impl VideoView {
60  pub fn new(_cx: &mut Context<Self>) -> Self {
61    VideoView {
62      image: None,
63      dims: None,
64      retired: Vec::new(),
65      fit: VideoFit::default(),
66      background: None,
67      width: None,
68      height: None,
69      radius: None,
70      placeholder: "No signal".into(),
71      shown: 0,
72      feeds: Vec::new(),
73    }
74  }
75
76  /// How frames fill the box. Default `Contain`.
77  pub fn fit(mut self, fit: VideoFit) -> Self {
78    self.fit = fit;
79    self
80  }
81
82  /// The colour behind a letterboxed frame. Default: the theme's surface.
83  pub fn background(mut self, color: impl Into<Hsla>) -> Self {
84    self.background = Some(color.into());
85    self
86  }
87
88  /// Fixed width in px; the default fills the parent.
89  pub fn width(mut self, width: f32) -> Self {
90    self.width = Some(width);
91    self
92  }
93
94  /// Fixed height in px; the default fills the parent.
95  pub fn height(mut self, height: f32) -> Self {
96    self.height = Some(height);
97    self
98  }
99
100  /// Corner radius token.
101  pub fn radius(mut self, radius: Size) -> Self {
102    self.radius = Some(radius);
103    self
104  }
105
106  /// What to show before the first frame and after `clear`.
107  pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
108    self.placeholder = text.into();
109    self
110  }
111
112  /// Show `frame`. Call from the UI thread.
113  pub fn push_frame(&mut self, frame: VideoFrame, cx: &mut Context<Self>) {
114    let dims = (frame.width(), frame.height());
115    let Some(image) = frame.into_image() else {
116      return;
117    };
118    if let Some(old) = self.image.replace(image) {
119      self.retired.push(old);
120    }
121    self.shown += 1;
122    if self.dims.replace(dims) != Some(dims) {
123      cx.emit(VideoEvent::Resized {
124        width: dims.0,
125        height: dims.1,
126      });
127    }
128    cx.notify();
129  }
130
131  /// Drop the picture and show the placeholder again.
132  pub fn clear(&mut self, cx: &mut Context<Self>) {
133    if let Some(old) = self.image.take() {
134      self.retired.push(old);
135    }
136    self.dims = None;
137    cx.notify();
138  }
139
140  /// The current picture's size, if one is showing.
141  pub fn frame_size(&self) -> Option<(u32, u32)> {
142    self.dims
143  }
144
145  /// Frames shown, and frames feeds overwrote before they were shown.
146  pub fn stats(&self) -> VideoStats {
147    let sent: u64 = self.feeds.iter().map(VideoFeed::sent).sum();
148    VideoStats {
149      shown: self.shown,
150      dropped: sent.saturating_sub(self.shown),
151    }
152  }
153
154  /// A handle a decoder thread can `send` frames into. The view polls it and
155  /// shows the newest; it stops when every clone is dropped.
156  pub fn feed(&mut self, cx: &mut Context<Self>) -> VideoFeed {
157    let feed = VideoFeed::new();
158    let poller = feed.clone();
159    self.feeds.push(feed.clone());
160    cx.spawn(async move |this, cx| loop {
161      cx.background_executor().timer(POLL).await;
162      if poller.orphaned() {
163        break;
164      }
165      let Some(frame) = poller.take() else {
166        continue;
167      };
168      if this
169        .update(cx, |view, cx| view.push_frame(frame, cx))
170        .is_err()
171      {
172        break;
173      }
174    })
175    .detach();
176    feed
177  }
178}
179
180impl Render for VideoView {
181  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
182    for old in self.retired.drain(..) {
183      let _ = window.drop_image(old);
184    }
185    let t = theme(cx);
186    let bg = self.background.unwrap_or_else(|| t.surface().hsla());
187    let dimmed = t.dimmed().hsla();
188    let radius = t.radius(self.radius.unwrap_or(t.default_radius));
189
190    let mut root = div()
191      .id("guise-videoview")
192      .flex()
193      .items_center()
194      .justify_center()
195      .overflow_hidden()
196      .bg(bg)
197      .rounded(px(radius));
198    root = match self.width {
199      Some(w) => root.w(px(w)),
200      None => root.w_full(),
201    };
202    root = match self.height {
203      Some(h) => root.h(px(h)),
204      None => root.h_full(),
205    };
206
207    let (image, dims) = (self.image.clone(), self.dims);
208    let fit = self.fit;
209    let root = match (image, dims) {
210      (Some(image), Some((w, h))) => root.child(
211        canvas(
212          |_, _, _| (),
213          move |bounds, _, window, _| {
214            let dst = (f32::from(bounds.size.width), f32::from(bounds.size.height));
215            let (x, y, fw, fh) = fit_rect((w as f32, h as f32), dst, fit);
216            let target = Bounds::new(
217              bounds.origin + gpui::point(px(x), px(y)),
218              size(px(fw), px(fh)),
219            );
220            // Rounded corners only read right when the picture reaches the
221            // box's edges; a letterboxed one keeps square corners.
222            let corners = if fw >= dst.0 - 0.5 && fh >= dst.1 - 0.5 {
223              Corners::all(px(radius))
224            } else {
225              Corners::default()
226            };
227            window.with_content_mask(Some(ContentMask { bounds }), |window| {
228              let _ = window.paint_image(target, corners, image.clone(), 0, false);
229            });
230          },
231        )
232        .size_full(),
233      ),
234      _ => root.text_color(dimmed).child(self.placeholder.clone()),
235    };
236    root
237      .probe("VideoView")
238      .attr("fit", format!("{:?}", self.fit))
239      .attr("frames", format!("{}", self.shown))
240  }
241}