Skip to main content

mimick/library/
video_player.rs

1mod controls;
2mod pipeline;
3mod tracks;
4
5use std::cell::Cell;
6use std::rc::{Rc, Weak};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use gst_play::{PlayMediaInfo, PlayMessage, PlayState};
11use gstreamer as gst;
12use gstreamer_play as gst_play;
13use gtk::prelude::*;
14use parking_lot::Mutex;
15
16use controls::{Controls, NORMAL_SPEED, SPEEDS, format_time, speed_label, volume_icon};
17use pipeline::frame_duration;
18
19const SKIP: gst::ClockTime = gst::ClockTime::from_seconds(10);
20const DEFAULT_FRAME: gst::ClockTime = gst::ClockTime::from_nseconds(33_333_333);
21const HIDE_DELAY: Duration = Duration::from_secs(3);
22const SEEK_SETTLE: Duration = Duration::from_millis(400);
23
24pub(super) struct VideoSource {
25    pub(super) uri: String,
26    pub(super) api_key: Option<String>,
27}
28
29pub(super) struct VideoHooks {
30    pub(super) start: Box<dyn Fn()>,
31    pub(super) fullscreen: Box<dyn Fn()>,
32    pub(super) error: Box<dyn Fn(String)>,
33}
34
35pub(super) struct VideoPlayer {
36    play: gst_play::Play,
37    api_key: Arc<Mutex<Option<String>>>,
38    _bus_watch: gst::bus::BusWatchGuard,
39    surface: gtk::Picture,
40    badge: gtk::Button,
41    controls: Controls,
42    hooks: VideoHooks,
43    state: Cell<PlayState>,
44    available: Cell<bool>,
45    loaded: Cell<bool>,
46    restarting: Cell<bool>,
47    last_seek: Cell<Option<Instant>>,
48    frame: Cell<gst::ClockTime>,
49    track_counts: Cell<(u32, u32)>,
50    pointer: Cell<(f64, f64)>,
51    hide_at: Cell<Instant>,
52    hide_armed: Cell<bool>,
53}
54
55impl VideoPlayer {
56    pub(super) fn new(
57        badge: &gtk::Button,
58        volume: f64,
59        muted: bool,
60        hooks: VideoHooks,
61    ) -> Result<Rc<Self>, String> {
62        let api_key = Arc::new(Mutex::new(None));
63        let (play, paintable) = pipeline::build_play(volume, muted, api_key.clone())?;
64        let bus = play.message_bus();
65        let player = Rc::new_cyclic(|weak: &Weak<Self>| Self {
66            _bus_watch: watch_bus(&bus, weak.clone()),
67            play,
68            api_key,
69            surface: video_surface(&paintable),
70            badge: badge.clone(),
71            controls: Controls::new(volume, muted),
72            hooks,
73            state: Cell::new(PlayState::Stopped),
74            available: Cell::new(false),
75            loaded: Cell::new(false),
76            restarting: Cell::new(false),
77            last_seek: Cell::new(None),
78            frame: Cell::new(DEFAULT_FRAME),
79            track_counts: Cell::new((0, 0)),
80            pointer: Cell::new((0.0, 0.0)),
81            hide_at: Cell::new(Instant::now()),
82            hide_armed: Cell::new(false),
83        });
84        player.connect_buttons();
85        player.connect_volume();
86        player.connect_pointer();
87        Ok(player)
88    }
89
90    pub(super) fn surface(&self) -> &gtk::Picture {
91        &self.surface
92    }
93
94    pub(super) fn controls(&self) -> &gtk::Revealer {
95        &self.controls.revealer
96    }
97
98    pub(super) fn volume(&self) -> f64 {
99        self.controls.volume.value()
100    }
101
102    pub(super) fn muted(&self) -> bool {
103        self.controls.mute.is_active()
104    }
105
106    pub(super) fn bind_compact(&self, split: &libadwaita::OverlaySplitView) {
107        for button in [&self.controls.back, &self.controls.forward] {
108            split
109                .bind_property("collapsed", button, "visible")
110                .invert_boolean()
111                .sync_create()
112                .build();
113        }
114        for margin in ["margin-start", "margin-end", "margin-bottom"] {
115            split
116                .bind_property("collapsed", &self.controls.revealer, margin)
117                .transform_to(|_, collapsed: bool| Some(if collapsed { 6 } else { 12 }))
118                .sync_create()
119                .build();
120        }
121    }
122
123    pub(super) fn prepare(self: &Rc<Self>, available: bool) {
124        self.play.stop();
125        self.available.set(available);
126        self.loaded.set(false);
127        self.restarting.set(false);
128        self.frame.set(DEFAULT_FRAME);
129        self.track_counts.set((0, 0));
130        self.controls.tracks.set_visible(false);
131        self.controls.revealer.set_visible(available);
132        self.show_duration(None);
133        self.state.set(PlayState::Stopped);
134        self.apply_state();
135    }
136
137    pub(super) fn load(&self, source: VideoSource) {
138        *self.api_key.lock() = source.api_key;
139        self.loaded.set(true);
140        self.play.set_uri(Some(&source.uri));
141        self.play.set_subtitle_track_enabled(false);
142        self.play.play();
143    }
144
145    pub(super) fn toggle_play(&self) {
146        if !self.available.get() {
147            return;
148        }
149        if !self.loaded.get() {
150            (self.hooks.start)();
151        } else if self.is_playing() {
152            self.play.pause();
153        } else {
154            self.play.play();
155        }
156    }
157
158    pub(super) fn pause(&self) {
159        if self.is_playing() {
160            self.play.pause();
161        }
162    }
163
164    pub(super) fn stop(&self) {
165        self.restarting.set(false);
166        self.play.stop();
167    }
168
169    pub(super) fn skip(&self, forward: bool) {
170        let Some(position) = self.active_position() else {
171            return;
172        };
173        let target = if forward {
174            position + SKIP
175        } else {
176            position.saturating_sub(SKIP)
177        };
178        self.play
179            .seek(self.play.duration().map_or(target, |d| target.min(d)));
180    }
181
182    pub(super) fn step_frame(&self, forward: bool) {
183        let Some(position) = self.active_position() else {
184            return;
185        };
186        self.pause();
187        let frame = self.frame.get();
188        self.play.seek(if forward {
189            position + frame
190        } else {
191            position.saturating_sub(frame)
192        });
193    }
194
195    pub(super) fn toggle_mute(&self) {
196        self.controls
197            .mute
198            .set_active(!self.controls.mute.is_active());
199    }
200
201    pub(super) fn set_fullscreen(&self, fullscreen: bool) {
202        let (icon, tooltip) = if fullscreen {
203            ("view-restore-symbolic", "Exit fullscreen (F11)")
204        } else {
205            ("view-fullscreen-symbolic", "Fullscreen (F11)")
206        };
207        self.controls.fullscreen.set_icon_name(icon);
208        self.controls.fullscreen.set_tooltip_text(Some(tooltip));
209    }
210
211    fn is_playing(&self) -> bool {
212        matches!(self.state.get(), PlayState::Playing | PlayState::Buffering)
213    }
214
215    fn active_position(&self) -> Option<gst::ClockTime> {
216        if self.state.get() == PlayState::Stopped {
217            return None;
218        }
219        self.play.position()
220    }
221
222    fn handle_message(self: &Rc<Self>, message: &gst::Message) {
223        let Ok(message) = PlayMessage::parse(message) else {
224            return;
225        };
226        match message {
227            PlayMessage::StateChanged(m) => self.on_state_changed(m.state()),
228            PlayMessage::PositionUpdated(m) => self.show_position(m.position()),
229            PlayMessage::SeekDone(m) => self.show_position(m.position()),
230            PlayMessage::DurationChanged(m) => self.show_duration(m.duration()),
231            PlayMessage::MediaInfoUpdated(m) => self.show_media_info(m.media_info()),
232            PlayMessage::EndOfStream(_) => self.on_end_of_stream(),
233            PlayMessage::Error(m) => {
234                log::warn!("Video playback failed: {}", m.error());
235                (self.hooks.error)(m.error().message().to_string());
236            }
237            _ => {}
238        }
239    }
240
241    fn on_state_changed(self: &Rc<Self>, state: PlayState) {
242        if self.restarting.get() {
243            if state == PlayState::Stopped {
244                return;
245            }
246            if state == PlayState::Playing {
247                self.restarting.set(false);
248                self.apply_speed();
249            }
250        }
251        self.state.set(state);
252        self.apply_state();
253    }
254
255    fn on_end_of_stream(&self) {
256        if self.controls.repeat.is_active() {
257            self.restarting.set(true);
258            self.play.play();
259        }
260    }
261
262    fn apply_state(self: &Rc<Self>) {
263        let active = self.state.get() != PlayState::Stopped;
264        let playing = self.is_playing();
265        let c = &self.controls;
266        self.surface.set_visible(active);
267        self.badge.set_visible(self.available.get() && !active);
268        let (icon, tooltip) = if playing {
269            ("media-playback-pause-symbolic", "Pause (Space)")
270        } else {
271            ("media-playback-start-symbolic", "Play (Space)")
272        };
273        c.play.set_icon_name(icon);
274        c.play.set_tooltip_text(Some(tooltip));
275        c.stop.set_sensitive(active);
276        c.back.set_sensitive(active);
277        c.forward.set_sensitive(active);
278        c.seek.set_sensitive(active);
279        c.speed.set_sensitive(active);
280        if !active {
281            c.speeds[NORMAL_SPEED].set_active(true);
282            self.set_position(gst::ClockTime::ZERO);
283        }
284        if playing {
285            self.bump_controls();
286        } else {
287            self.reveal_controls();
288        }
289    }
290
291    fn apply_speed(&self) {
292        let index = self.speed_index();
293        self.controls.speed.set_label(&speed_label(SPEEDS[index]));
294        if self.state.get() != PlayState::Stopped && !self.restarting.get() {
295            self.play.set_rate(SPEEDS[index]);
296        }
297    }
298
299    fn speed_index(&self) -> usize {
300        self.controls
301            .speeds
302            .iter()
303            .position(gtk::CheckButton::is_active)
304            .unwrap_or(NORMAL_SPEED)
305    }
306
307    fn show_position(&self, position: Option<gst::ClockTime>) {
308        let settling = self
309            .last_seek
310            .get()
311            .is_some_and(|t| t.elapsed() < SEEK_SETTLE);
312        if self.state.get() == PlayState::Stopped || settling {
313            return;
314        }
315        self.set_position(position.unwrap_or(gst::ClockTime::ZERO));
316    }
317
318    fn set_position(&self, position: gst::ClockTime) {
319        self.controls.seek.set_value(position.seconds_f64());
320        self.controls.elapsed.set_label(&format_time(position));
321    }
322
323    fn show_duration(&self, duration: Option<gst::ClockTime>) {
324        let duration = duration.unwrap_or(gst::ClockTime::ZERO);
325        self.controls
326            .seek
327            .set_range(0.0, duration.seconds_f64().max(1.0));
328        self.controls.total.set_label(&format_time(duration));
329    }
330
331    fn seek_to(&self, seconds: f64) {
332        self.last_seek.set(Some(Instant::now()));
333        let target = gst::ClockTime::from_seconds_f64(seconds.max(0.0));
334        self.controls.elapsed.set_label(&format_time(target));
335        self.play.seek(target);
336    }
337
338    fn show_media_info(self: &Rc<Self>, info: &PlayMediaInfo) {
339        if let Some(frame) = info.video_streams().first().and_then(|video| {
340            let rate = video.framerate();
341            frame_duration(rate.numer(), rate.denom())
342        }) {
343            self.frame.set(frame);
344        }
345        let counts = (
346            info.number_of_audio_streams(),
347            info.number_of_subtitle_streams(),
348        );
349        if self.track_counts.replace(counts) == counts {
350            return;
351        }
352        self.controls
353            .tracks
354            .set_visible(counts.0 > 1 || counts.1 > 0);
355        self.controls
356            .tracks
357            .set_popover(Some(&self.track_popover(info)));
358    }
359
360    fn connect_buttons(self: &Rc<Self>) {
361        let c = &self.controls;
362        c.play.connect_clicked(self.on_click(|p| p.toggle_play()));
363        c.stop.connect_clicked(self.on_click(|p| p.stop()));
364        c.back.connect_clicked(self.on_click(|p| p.skip(false)));
365        c.forward.connect_clicked(self.on_click(|p| p.skip(true)));
366        c.fullscreen
367            .connect_clicked(self.on_click(|p| (p.hooks.fullscreen)()));
368        for check in &c.speeds {
369            check.connect_toggled(self.on_picked(|p| p.apply_speed()));
370        }
371        let weak = Rc::downgrade(self);
372        c.seek.connect_change_value(move |_, _, value| {
373            if let Some(p) = weak.upgrade() {
374                p.seek_to(value);
375            }
376            glib::Propagation::Proceed
377        });
378    }
379
380    fn connect_volume(self: &Rc<Self>) {
381        let weak = Rc::downgrade(self);
382        self.controls.volume.connect_value_changed(move |scale| {
383            if let Some(p) = weak.upgrade() {
384                p.play.set_volume(scale.value());
385                p.update_volume_icon();
386            }
387        });
388        let weak = Rc::downgrade(self);
389        self.controls.mute.connect_toggled(move |mute| {
390            if let Some(p) = weak.upgrade() {
391                p.play.set_mute(mute.is_active());
392                p.update_volume_icon();
393            }
394        });
395    }
396
397    fn connect_pointer(self: &Rc<Self>) {
398        let weak = Rc::downgrade(self);
399        self.controls.hover.connect_motion(move |_, _, _| {
400            if let Some(p) = weak.upgrade() {
401                p.bump_controls();
402            }
403        });
404        let motion = gtk::EventControllerMotion::new();
405        let weak = Rc::downgrade(self);
406        motion.connect_motion(move |_, x, y| {
407            if let Some(p) = weak.upgrade()
408                && p.pointer.replace((x, y)) != (x, y)
409            {
410                p.bump_controls();
411            }
412        });
413        self.surface.add_controller(motion);
414        let click = gtk::GestureClick::new();
415        let weak = Rc::downgrade(self);
416        click.connect_pressed(move |_, n_press, _, _| {
417            let Some(p) = weak.upgrade() else {
418                return;
419            };
420            match n_press {
421                1 => p.bump_controls(),
422                2 => (p.hooks.fullscreen)(),
423                _ => {}
424            }
425        });
426        self.surface.add_controller(click);
427    }
428
429    fn on_click<F: Fn(&Rc<Self>) + 'static>(
430        self: &Rc<Self>,
431        f: F,
432    ) -> impl Fn(&gtk::Button) + use<F> {
433        let weak = Rc::downgrade(self);
434        move |_| {
435            if let Some(p) = weak.upgrade() {
436                f(&p);
437            }
438        }
439    }
440
441    fn on_picked<F: Fn(&Rc<Self>) + 'static>(
442        self: &Rc<Self>,
443        f: F,
444    ) -> impl Fn(&gtk::CheckButton) + use<F> {
445        let weak = Rc::downgrade(self);
446        move |check| {
447            if check.is_active()
448                && let Some(p) = weak.upgrade()
449            {
450                f(&p);
451            }
452        }
453    }
454
455    fn update_volume_icon(&self) {
456        self.controls
457            .volume_menu
458            .set_icon_name(volume_icon(self.volume(), self.muted()));
459    }
460
461    fn reveal_controls(&self) {
462        self.controls.revealer.set_reveal_child(true);
463        self.surface.set_cursor(None::<&gtk::gdk::Cursor>);
464    }
465
466    fn bump_controls(self: &Rc<Self>) {
467        self.reveal_controls();
468        self.hide_at.set(Instant::now() + HIDE_DELAY);
469        if !self.hide_armed.replace(true) {
470            self.arm_hide(HIDE_DELAY);
471        }
472    }
473
474    fn arm_hide(self: &Rc<Self>, delay: Duration) {
475        let weak = Rc::downgrade(self);
476        glib::timeout_add_local_once(delay, move || {
477            let Some(p) = weak.upgrade() else {
478                return;
479            };
480            let remaining = p.hide_at.get().saturating_duration_since(Instant::now());
481            if !remaining.is_zero() {
482                p.arm_hide(remaining);
483                return;
484            }
485            p.hide_armed.set(false);
486            if p.is_playing() && !p.controls_in_use() {
487                p.controls.revealer.set_reveal_child(false);
488                p.surface.set_cursor_from_name(Some("none"));
489            }
490        });
491    }
492
493    fn controls_in_use(&self) -> bool {
494        let c = &self.controls;
495        c.hover.contains_pointer()
496            || c.volume_menu.is_active()
497            || c.tracks.is_active()
498            || c.speed.is_active()
499    }
500}
501
502fn watch_bus(bus: &gst::Bus, player: Weak<VideoPlayer>) -> gst::bus::BusWatchGuard {
503    bus.add_watch_local(move |_, message| {
504        if let Some(player) = player.upgrade() {
505            player.handle_message(message);
506        }
507        glib::ControlFlow::Continue
508    })
509    .expect("a new GstPlay bus has no watch")
510}
511
512fn video_surface(paintable: &gtk::gdk::Paintable) -> gtk::Picture {
513    gtk::Picture::builder()
514        .paintable(paintable)
515        .content_fit(gtk::ContentFit::Contain)
516        .hexpand(true)
517        .vexpand(true)
518        .visible(false)
519        .css_classes(["mimick-video-surface"])
520        .build()
521}