makepad_widgets/
video.rs

1use crate::{
2    makepad_derive_widget::*, makepad_draw::*, makepad_platform::event::video_playback::*,
3    widget::*, image_cache::ImageCacheImpl,
4};
5
6live_design! {
7    link widgets;
8    use link::shaders::*;
9    
10    pub VideoBase = {{Video}} {}
11    pub Video = <VideoBase> {
12        width: 100, height: 100
13        
14        draw_bg: {
15            shape: Solid,
16            fill: Image
17            texture video_texture: textureOES
18            texture thumbnail_texture: texture2d
19            uniform show_thumbnail: 0.0
20            
21            instance opacity: 1.0
22            instance image_scale: vec2(1.0, 1.0)
23            instance image_pan: vec2(0.5, 0.5)
24            
25            uniform source_size: vec2(1.0, 1.0)
26            uniform target_size: vec2(-1.0, -1.0)
27            
28            fn get_color_scale_pan(self) -> vec4 {
29                // Early return for default scaling and panning,
30                // used when walk size is not specified or non-fixed.
31                if self.target_size.x <= 0.0 && self.target_size.y <= 0.0 {
32                    if self.show_thumbnail > 0.0 {
33                        return sample2d(self.thumbnail_texture, self.pos).xyzw;
34                    } else {
35                        return sample2dOES(self.video_texture, self.pos);
36                    }  
37                }
38                
39                let scale = self.image_scale;
40                let pan = self.image_pan;
41                let source_aspect_ratio = self.source_size.x / self.source_size.y;
42                let target_aspect_ratio = self.target_size.x / self.target_size.y;
43                
44                // Adjust scale based on aspect ratio difference
45                if (source_aspect_ratio != target_aspect_ratio) {
46                    if (source_aspect_ratio > target_aspect_ratio) {
47                        scale.x = target_aspect_ratio / source_aspect_ratio;
48                        scale.y = 1.0;
49                    } else {
50                        scale.x = 1.0;
51                        scale.y = source_aspect_ratio / target_aspect_ratio;
52                    }
53                }
54                
55                // Calculate the range for panning
56                let pan_range_x = max(0.0, (1.0 - scale.x));
57                let pan_range_y = max(0.0, (1.0 - scale.y));
58                
59                // Adjust the user pan values to be within the pan range
60                let adjusted_pan_x = pan_range_x * pan.x;
61                let adjusted_pan_y = pan_range_y * pan.y;
62                let adjusted_pan = vec2(adjusted_pan_x, adjusted_pan_y);
63                let adjusted_pos = (self.pos * scale) + adjusted_pan;
64                
65                if self.show_thumbnail > 0.5 {
66                    return sample2d(self.thumbnail_texture, adjusted_pos).xyzw;
67                } else {
68                    return sample2dOES(self.video_texture, adjusted_pos);
69                }      
70            }
71            
72            fn pixel(self) -> vec4 {
73                let color = self.get_color_scale_pan();
74                return Pal::premul(vec4(color.xyz, color.w * self.opacity));
75            }
76        }
77    }
78}
79
80/// Currently only supported on Android
81
82/// DSL Usage
83/// 
84/// `source` - determines the source for the video playback, can be either:
85///  - `Network { url: "https://www.someurl.com/video.mkv" }`. On Android it supports: HLS, DASH, RTMP, RTSP, and progressive HTTP downloads
86///  - `Filesystem { path: "/storage/.../DCIM/Camera/video.mp4" }`. On Android it requires read permissions that must be granted at runtime.
87///  - `Dependency { path: dep("crate://self/resources/video.mp4") }`. For in-memory videos loaded through LiveDependencies
88/// 
89/// `thumbnail_source` - determines the source for the thumbnail image, currently only supports LiveDependencies.
90/// 
91/// `is_looping` - determines if the video should be played in a loop. defaults to false.
92/// 
93/// `hold_to_pause` - determines if the video should be paused when the user hold the pause button. defaults to false.
94/// 
95/// `autoplay` - determines if the video should start playback when the widget is created. defaults to false.
96
97/// Not yet supported:
98/// UI
99///  - Playback controls
100///  - Progress/seek-to bar
101
102/// Widget API
103///  - Seek to timestamp
104///  - Option to restart playback manually when not looping.
105///  - Hotswap video source, `set_source(VideoDataSource)` only works if video is in Unprepared state.
106
107#[derive(Live, Widget)]
108pub struct Video {
109    // Drawing
110    #[redraw] #[live]
111    draw_bg: DrawColor,
112    #[walk]
113    walk: Walk,
114    #[live]
115    layout: Layout,
116    #[live]
117    scale: f64,
118
119    // Textures
120    #[live]
121    source: VideoDataSource,
122    #[rust]
123    video_texture: Option<Texture>,
124    #[rust]
125    video_texture_handle: Option<u32>,
126    /// Requires [`show_thumbnail_before_playback`] to be `true`.
127    #[live]
128    thumbnail_source: Option<LiveDependency>,
129    #[rust]
130    thumbnail_texture: Option<Texture>,
131
132    // Playback
133    #[live(false)]
134    is_looping: bool,
135    #[live(false)]
136    hold_to_pause: bool,
137    #[live(false)]
138    autoplay: bool,
139    #[live(false)]
140    mute: bool,
141    #[rust]
142    playback_state: PlaybackState,
143    #[rust]
144    should_prepare_playback: bool,
145    #[rust]
146    audio_state: AudioState,
147    /// Whether to show the provided thumbnail when the video has not yet started playing.
148    #[live(false)]
149    show_thumbnail_before_playback: bool,
150
151    // Actions
152    #[rust(false)]
153    should_dispatch_texture_updates: bool,
154
155    // Original video metadata
156    #[rust]
157    video_width: usize,
158    #[rust]
159    video_height: usize,
160    #[rust]
161    total_duration: u128,
162
163    #[rust]
164    id: LiveId,
165}
166
167impl VideoRef {
168    /// Prepares the video for playback. Does not start playback or update the video texture.
169    /// 
170    /// Once playback is prepared, [`begin_playback`] can be called to start the actual playback.
171    /// 
172    /// Alternatively, [`begin_playback`] (which uses [`prepare_playback`]) can be called if you want to start playback as soon as it's prepared.
173    pub fn prepare_playback(&self, cx: &mut Cx) {
174        if let Some(mut inner) = self.borrow_mut() {
175            inner.prepare_playback(cx);
176        }
177    }
178
179    /// Starts the video playback. Calls `prepare_playback(cx)` if the video not already prepared.
180    pub fn begin_playback(&self, cx: &mut Cx) {
181        if let Some(mut inner) = self.borrow_mut() {
182            inner.begin_playback(cx);
183        }
184    }
185
186    /// Pauses the video playback. Ignores if the video is not currently playing.
187    pub fn pause_playback(&self, cx: &mut Cx) {
188        if let Some(mut inner) = self.borrow_mut() {
189            inner.pause_playback(cx);
190        }
191    }
192
193    /// Pauses the video playback. Ignores if the video is already playing.
194    pub fn resume_playback(&self, cx: &mut Cx) {
195        if let Some(mut inner) = self.borrow_mut() {
196            inner.resume_playback(cx);
197        }
198    }
199
200    /// Mutes the video playback. Ignores if the video is not currently playing or already muted.
201    pub fn mute_playback(&self, cx: &mut Cx) {
202        if let Some(mut inner) = self.borrow_mut() {
203            inner.mute_playback(cx);
204        }
205    }
206
207    /// Unmutes the video playback. Ignores if the video is not currently muted or not playing.
208    pub fn unmute_playback(&self, cx: &mut Cx) {
209        if let Some(mut inner) = self.borrow_mut() {
210            inner.unmute_playback(cx);
211        }
212    }
213
214    /// Stops playback and performs cleanup of all resources related to playback,
215    /// including data source, decoding threads, object references, etc.
216    /// 
217    /// In order to play the video again you must either call [`prepare_playback`] or [`begin_playback`].
218    pub fn stop_and_cleanup_resources(&self, cx: &mut Cx) {
219        if let Some(mut inner) = self.borrow_mut() {
220            inner.stop_and_cleanup_resources(cx);
221        }
222    }
223
224    /// Updates the source of the video data. Currently it only proceeds if the video is in Unprepared state.
225    pub fn set_source(&self, source: VideoDataSource) {
226        if let Some(mut inner) = self.borrow_mut() {
227            inner.set_source(source);
228        }
229    }
230
231    /// Determines if this video instance should dispatch [`VideoAction::TextureUpdated`] actions on each texture update.
232    /// This is disbaled by default because it can be quite nosiy when debugging actions.
233    pub fn should_dispatch_texture_updates(&self, should_dispatch: bool) {
234        if let Some(mut inner) = self.borrow_mut() {
235            inner.should_dispatch_texture_updates = should_dispatch;
236        }
237    }
238
239    pub fn set_thumbnail_texture(&self, cx: &mut Cx, texture: Option<Texture>) {
240        if let Some(mut inner) = self.borrow_mut() {
241            inner.thumbnail_texture = texture;
242            inner.load_thumbnail_image(cx);
243        }
244    }
245
246    pub fn is_unprepared(&self) -> bool {
247        if let Some(inner) = self.borrow() {
248            return inner.playback_state == PlaybackState::Unprepared
249        }
250        false
251    }
252
253    pub fn is_preparing(&self) -> bool {
254        if let Some(inner) = self.borrow() {
255            return inner.playback_state == PlaybackState::Preparing
256        }
257        false
258    }
259
260    pub fn is_prepared(&self) -> bool {
261        if let Some(inner) = self.borrow() {
262            return inner.playback_state == PlaybackState::Prepared
263        }
264        false
265    }
266    
267    pub fn is_playing(&self) -> bool {
268        if let Some(inner) = self.borrow() {
269            return inner.playback_state == PlaybackState::Playing
270        }
271        false
272    }
273
274    pub fn is_paused(&self) -> bool {
275        if let Some(inner) = self.borrow() {
276            return inner.playback_state == PlaybackState::Paused
277        }
278        false
279    }
280
281    pub fn has_completed(&self) -> bool {
282        if let Some(inner) = self.borrow() {
283            return inner.playback_state == PlaybackState::Completed
284        }
285        false
286    }
287
288    pub fn is_cleaning_up(&self) -> bool {
289        if let Some(inner) = self.borrow() {
290            return inner.playback_state == PlaybackState::CleaningUp
291        }
292        false
293    }
294
295    pub fn is_muted(&self) -> bool {
296        if let Some(inner) = self.borrow() {
297            return inner.audio_state == AudioState::Muted
298        }
299        false
300    }
301}
302
303#[derive(Default, PartialEq, Debug)]
304enum PlaybackState {
305    #[default]
306    Unprepared,
307    Preparing,
308    Prepared,
309    Playing,
310    Paused,
311    /// When playback reached end of stream, only observable when not looping.
312    Completed,
313    /// When the platform is called to stop playback and release all resources
314    /// including data source, object references, decoding threads, etc.
315    /// 
316    /// Once cleanup has completed, the video will go into `Unprepared` state.
317    CleaningUp,
318}
319
320#[derive(Default, PartialEq, Debug)]
321enum AudioState {
322    #[default]
323    Playing,
324    Muted,
325}
326
327impl LiveHook for Video {
328    #[allow(unused)]
329    fn after_new_from_doc(&mut self, cx: &mut Cx) {
330        self.id = LiveId::unique();
331
332        #[cfg(target_os = "android")]
333        {
334            if self.video_texture.is_none() {
335                let new_texture = Texture::new_with_format(cx, TextureFormat::VideoRGB);
336                self.video_texture = Some(new_texture);
337            }
338            let texture = self.video_texture.as_mut().unwrap();
339            self.draw_bg.draw_vars.set_texture(0, &texture);
340        }
341
342        #[cfg(not(target_os = "android"))]
343        error!("Video Widget is currently only supported on Android.");
344
345        match cx.os_type() {
346            OsType::Android(params) => {
347                if params.is_emulator {
348                    panic!("Video Widget is currently only supported on real devices. (unreliable support for external textures on some emulators hosts)");
349                }
350            },
351            _ => {}
352        }
353        
354        self.should_prepare_playback = self.autoplay;
355    }
356
357    fn after_apply(&mut self, cx: &mut Cx, _apply: &mut Apply, _index: usize, _nodes: &[LiveNode]) {
358        self.lazy_create_image_cache(cx);
359        self.thumbnail_texture = Some(Texture::new(cx));
360
361        let target_w = self.walk.width.fixed_or_zero();
362        let target_h = self.walk.height.fixed_or_zero();
363        self.draw_bg
364            .set_uniform(cx, id!(target_size), &[target_w as f32, target_h as f32]);
365
366        if self.show_thumbnail_before_playback {
367            self.load_thumbnail_image(cx);
368            self.draw_bg
369            .set_uniform(cx, id!(show_thumbnail), &[1.0]);
370        }
371    }
372}
373
374#[derive(Clone, Debug, DefaultNone)]
375pub enum VideoAction {
376    None,
377    PlaybackPrepared,
378    PlaybackBegan,
379    TextureUpdated,
380    PlaybackCompleted,
381    PlayerReset,
382    // The video view was secondary clicked (right-clicked) or long-pressed.
383    SecondaryClicked {
384        abs: DVec2,
385        modifiers: KeyModifiers,
386    }
387}
388
389impl Widget for Video {
390
391    fn draw_walk(&mut self, cx: &mut Cx2d, _scope:&mut Scope, walk: Walk) -> DrawStep {
392        if let Some(texture) = &self.thumbnail_texture {
393            self.draw_bg.draw_vars.set_texture(1, texture);
394        }
395
396        self.draw_bg.draw_walk(cx, walk);
397        DrawStep::done()
398    }
399
400    fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope){
401        let uid = self.widget_uid();
402        match event{
403            Event::VideoPlaybackPrepared(event)=> if event.video_id == self.id {
404                self.handle_playback_prepared(cx, event);
405                cx.widget_action(uid, &scope.path, VideoAction::PlaybackPrepared);
406            }
407            Event::VideoTextureUpdated(event)=>if event.video_id == self.id {
408                self.redraw(cx);
409                if self.playback_state == PlaybackState::Prepared {
410                    self.playback_state = PlaybackState::Playing;
411                    cx.widget_action(uid, &scope.path, VideoAction::PlaybackBegan);
412                    self.draw_bg
413                    .set_uniform(cx, id!(show_thumbnail), &[0.0]);
414                }
415                if self.should_dispatch_texture_updates {
416                    cx.widget_action(uid, &scope.path, VideoAction::TextureUpdated);
417                }
418            }
419            Event::VideoPlaybackCompleted(event) =>  if event.video_id == self.id {
420                if !self.is_looping {
421                    self.playback_state = PlaybackState::Completed;
422                    cx.widget_action(uid, &scope.path, VideoAction::PlaybackCompleted);
423                }
424            }
425            Event::VideoPlaybackResourcesReleased(event) => if event.video_id == self.id {
426                self.playback_state = PlaybackState::Unprepared;
427                cx.widget_action(uid, &scope.path, VideoAction::PlayerReset);
428            }
429            Event::TextureHandleReady(event) => {
430                if event.texture_id == self.video_texture.clone().unwrap().texture_id() {
431                    self.video_texture_handle = Some(event.handle);
432                    self.maybe_prepare_playback(cx);
433                }
434            }
435            _=>()
436        }
437        
438        self.handle_gestures(cx, event, scope);
439        self.handle_activity_events(cx, event);
440        self.handle_errors(event);
441    }
442}
443
444impl ImageCacheImpl for Video {
445    fn get_texture(&self, _id:usize) -> &Option<Texture> {
446        &self.thumbnail_texture
447    }
448
449    fn set_texture(&mut self, texture: Option<Texture>, _id:usize) {
450        self.thumbnail_texture = texture;
451    }
452}
453
454impl Video {
455    fn maybe_prepare_playback(&mut self, cx: &mut Cx) {
456        if self.playback_state == PlaybackState::Unprepared && self.should_prepare_playback {
457            if self.video_texture_handle.is_none() {
458                // texture is not yet ready, this method will be called again on TextureHandleReady
459                return;
460            }
461
462            let source = match &self.source {
463                VideoDataSource::Dependency { path } => match cx.get_dependency(path.as_str()) {
464                    Ok(data) => VideoSource::InMemory(data),
465                    Err(e) => {
466                        error!(
467                            "Attempted to prepare playback: resource not found {} {}",
468                            path.as_str(),
469                            e
470                        );
471                        return;
472                    }
473                },
474                VideoDataSource::Network { url } => VideoSource::Network(url.to_string()),
475                VideoDataSource::Filesystem { path } => VideoSource::Filesystem(path.to_string()),
476            };
477
478            cx.prepare_video_playback(
479                self.id,
480                source,
481                self.video_texture_handle.unwrap(),
482                self.autoplay,
483                self.is_looping,
484            );
485
486            self.playback_state = PlaybackState::Preparing;
487            self.should_prepare_playback = false;
488        }
489    }
490
491    fn handle_playback_prepared(&mut self, cx: &mut Cx, event: &VideoPlaybackPreparedEvent) {
492        self.playback_state = PlaybackState::Prepared;
493        self.video_width = event.video_width as usize;
494        self.video_height = event.video_height as usize;
495        self.total_duration = event.duration;
496
497        self.draw_bg
498            .set_uniform(cx, id!(source_size), &[self.video_width as f32, self.video_height as f32]);
499
500        if self.mute && self.audio_state != AudioState::Muted {
501            cx.mute_video_playback(self.id);
502        }
503    }
504
505    fn handle_gestures(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
506        match event.hits(cx, self.draw_bg.area()) {
507            Hit::FingerDown(fe) if fe.is_primary_hit() => {
508                if self.hold_to_pause {
509                    self.pause_playback(cx);
510                }
511            }
512            Hit::FingerDown(fe) if fe.mouse_button().is_some_and(|mb| mb.is_secondary()) => {
513                self.handle_secondary_click(cx, scope, fe.abs, fe.modifiers);
514            }
515            Hit::FingerLongPress(lp) => {
516                // TODO: here we could offer some customization, e.g., setting playback speed to 2x.
517                // For now, we treat a long press just like a secondary click.
518                self.handle_secondary_click(cx, scope, lp.abs, Default::default());
519            }
520            Hit::FingerUp(fe) if fe.is_primary_hit() => {
521                if self.hold_to_pause {
522                    self.resume_playback(cx);
523                }
524            }
525            _ => (),
526        }
527    }
528
529    fn handle_activity_events(&mut self, cx: &mut Cx, event: &Event) {
530        match event {
531            Event::Pause => self.pause_playback(cx),
532            Event::Resume => self.resume_playback(cx),
533            _ => (),
534        }
535    }
536
537    fn handle_errors(&mut self, event: &Event) {
538        if let Event::VideoDecodingError(event) = event {
539            if event.video_id == self.id {
540                error!(
541                    "Error decoding video with id {} : {}",
542                    self.id.0, event.error
543                );
544            }
545        }
546    }
547
548    fn prepare_playback(&mut self, cx: &mut Cx) {
549        if self.playback_state == PlaybackState::Unprepared {
550            self.should_prepare_playback = true;
551            self.maybe_prepare_playback(cx);
552        }
553    }
554
555    fn begin_playback(&mut self, cx: &mut Cx) {
556        if self.playback_state == PlaybackState::Unprepared {
557            self.should_prepare_playback = true;
558            self.autoplay = true;
559            self.maybe_prepare_playback(cx);
560        } else if self.playback_state == PlaybackState::Prepared {
561            cx.begin_video_playback(self.id);
562        }
563    }
564
565    fn handle_secondary_click(
566        &mut self,
567        cx: &mut Cx,
568        scope: &mut Scope,
569        abs: DVec2,
570        modifiers: KeyModifiers,
571    ) {
572        cx.widget_action(
573            self.widget_uid(),
574            &scope.path,
575            VideoAction::SecondaryClicked {
576                abs,
577                modifiers,
578            }
579        );
580    }
581
582    fn pause_playback(&mut self, cx: &mut Cx) {
583        if self.playback_state != PlaybackState::Paused {
584            cx.pause_video_playback(self.id);
585            self.playback_state = PlaybackState::Paused;
586        }
587    }
588
589    fn resume_playback(&mut self, cx: &mut Cx) {
590        if self.playback_state == PlaybackState::Paused {
591            cx.resume_video_playback(self.id);
592            self.playback_state = PlaybackState::Playing;
593        }
594    }
595
596    fn mute_playback(&mut self, cx: &mut Cx) {
597        if self.playback_state == PlaybackState::Playing || self.playback_state == PlaybackState::Paused || self.playback_state == PlaybackState::Prepared {
598            cx.mute_video_playback(self.id);
599            self.audio_state = AudioState::Muted;
600        }
601    }
602
603    fn unmute_playback(&mut self, cx: &mut Cx) {
604        if self.playback_state == PlaybackState::Playing || self.playback_state == PlaybackState::Paused || self.playback_state == PlaybackState::Prepared
605        && self.audio_state == AudioState::Muted {
606            cx.unmute_video_playback(self.id);
607            self.audio_state = AudioState::Playing;
608        }
609    }
610
611    fn stop_and_cleanup_resources(&mut self, cx: &mut Cx) {
612        if self.playback_state != PlaybackState::Unprepared 
613            && self.playback_state != PlaybackState::Preparing
614            && self.playback_state != PlaybackState::CleaningUp {
615            cx.cleanup_video_playback_resources(self.id);
616            
617            self.playback_state = PlaybackState::CleaningUp;
618            self.autoplay = false;
619            self.should_prepare_playback = false;
620        }
621    }
622
623    fn set_source(&mut self, source: VideoDataSource) {
624        if self.playback_state == PlaybackState::Unprepared {
625            self.source = source;
626        } else {
627            error!(
628                "Attempted to set source while player {} state is: {:?}",
629                self.id.0,
630                self.playback_state
631            );
632        }
633    }
634
635    fn load_thumbnail_image(&mut self, cx: &mut Cx) {
636        if let Some(path) = self.thumbnail_source.clone() {
637            let path_str = path.as_str();
638
639            if path_str.len() > 0 {
640                let _ = self.load_image_dep_by_path(cx, path_str, 0);
641            }
642        }
643    }
644}
645
646/// The source of the video data.
647/// 
648/// [`Dependency`]: The path to a LiveDependency (an asset loaded with `dep("crate://..)`).
649/// 
650/// [`Network`]: The URL of a video file, it can be any regular HTTP download or HLS, DASH, RTMP, RTSP.
651/// 
652/// [`Filesystem`]: The path to a video file on the local filesystem. This requires runtime-approved permissions for reading storage.
653#[derive(Clone, Debug, Live, LiveHook)]
654#[live_ignore]
655pub enum VideoDataSource {
656    #[live {path: LiveDependency::default()}]
657    Dependency { path: LiveDependency },
658    #[pick {url: "".to_string()}]
659    Network { url: String },
660    #[live {path: "".to_string()}]
661    Filesystem { path: String },
662}