1use 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
43const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum SurfaceContent {
52 Frame,
54 Poster,
56 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#[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 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 pub fn transport(mut self, transport: Rc<dyn MediaTransport>) -> Self {
130 self.transport = Some(transport);
131 self
132 }
133
134 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 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 pub fn ratio(mut self, ratio: f32) -> Self {
157 if ratio > 0.0 {
158 self.ratio = ratio;
159 }
160 self
161 }
162
163 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 pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
174 self.elapsed = Some(elapsed.into());
175 self
176 }
177
178 pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
180 self.remaining = Some(remaining.into());
181 self
182 }
183
184 pub fn step_seconds(mut self, seconds: f32) -> Self {
186 self.step = Some(seconds.max(0.0));
187 self
188 }
189
190 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 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 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 _ => 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
374fn 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}