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: >k::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) -> >k::Picture {
91 &self.surface
92 }
93
94 pub(super) fn controls(&self) -> >k::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(>k::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(>k::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::<>k::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: >k::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}