aboutsummaryrefslogtreecommitdiff
path: root/src/player.rs
diff options
context:
space:
mode:
authorMalte Voos <git@mal.tc>2025-10-01 00:20:10 +0200
committerMalte Voos <git@mal.tc>2025-10-01 00:20:10 +0200
commit338babaad2189f7ff1ee088994c8c20a0646ff4d (patch)
tree29fb2620f748d32a42c1d1eb3346771600a8d75b /src/player.rs
downloadlleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.tar.gz
lleap-338babaad2189f7ff1ee088994c8c20a0646ff4d.zip
init
Diffstat (limited to 'src/player.rs')
-rw-r--r--src/player.rs298
1 files changed, 298 insertions, 0 deletions
diff --git a/src/player.rs b/src/player.rs
new file mode 100644
index 0000000..c784a04
--- /dev/null
+++ b/src/player.rs
@@ -0,0 +1,298 @@
+use gst::bus::BusWatchGuard;
+use gst::prelude::*;
+use gst_play::{Play, PlayMessage, PlayVideoOverlayVideoRenderer};
+use gtk::gdk;
+use gtk::glib::{self, clone};
+use gtk::prelude::*;
+use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent};
+
+#[allow(dead_code)]
+pub struct Player {
+ // GStreamer-related
+ gtksink: gst::Element,
+ renderer: PlayVideoOverlayVideoRenderer,
+ player: Play,
+ bus_watch: BusWatchGuard,
+ // UI state
+ is_playing: bool,
+ duration: gst::ClockTime,
+ position: gst::ClockTime,
+ seeking: bool,
+}
+
+#[derive(Debug)]
+pub enum PlayerMsg {
+ SetUrl(String),
+ PlayPause,
+ Play,
+ Pause,
+ SeekTo(gst::ClockTime),
+ StartSeeking,
+ StopSeeking,
+ // messages from GStreamer
+ UpdatePosition(gst::ClockTime),
+ UpdateDuration(gst::ClockTime),
+}
+
+#[derive(Debug)]
+pub enum PlayerOutput {
+ PositionUpdate(gst::ClockTime),
+}
+
+fn format_time(time: gst::ClockTime) -> String {
+ let seconds = time.seconds();
+ let minutes = seconds / 60;
+ let hours = minutes / 60;
+
+ if hours > 0 {
+ format!("{}:{:02}:{:02}", hours, minutes % 60, seconds % 60)
+ } else {
+ format!("{}:{:02}", minutes, seconds % 60)
+ }
+}
+
+#[relm4::component(pub)]
+impl SimpleComponent for Player {
+ type Input = PlayerMsg;
+ type Output = PlayerOutput;
+ type Init = ();
+
+ view! {
+ gtk::Box {
+ set_orientation: gtk::Orientation::Vertical,
+
+ // Video area
+ gtk::Picture {
+ set_paintable: Some(&paintable),
+ set_vexpand: true,
+ },
+
+ // Control bar
+ gtk::Box {
+ set_orientation: gtk::Orientation::Horizontal,
+ set_spacing: 10,
+ set_margin_all: 10,
+
+ // Play/Pause button
+ #[name = "play_pause_btn"]
+ gtk::Button {
+ #[watch]
+ set_icon_name: if model.is_playing { "media-playback-pause" } else { "media-playback-start" },
+ connect_clicked => PlayerMsg::PlayPause,
+ add_css_class: "circular",
+ },
+
+ // Current time label
+ gtk::Label {
+ #[watch]
+ set_text: &format_time(model.position),
+ set_width_chars: 8,
+ },
+
+ // Seek slider
+ #[name = "seek_scale"]
+ gtk::Scale {
+ set_hexpand: true,
+ set_orientation: gtk::Orientation::Horizontal,
+ #[watch]
+ set_range: (0.0, model.duration.mseconds() as f64),
+ #[watch]
+ set_value?: if !model.seeking {
+ Some(model.position.mseconds() as f64)
+ } else {
+ None
+ },
+ connect_change_value[sender] => move |_, _, value| {
+ let position = gst::ClockTime::from_mseconds(value as u64);
+ sender.input(PlayerMsg::SeekTo(position));
+ glib::Propagation::Proceed
+ },
+ },
+
+ // Duration label
+ #[name = "duration_label"]
+ gtk::Label {
+ #[watch]
+ set_text: &format_time(model.duration),
+ set_width_chars: 8,
+ },
+ }
+ }
+ }
+
+ fn init(
+ _init: Self::Init,
+ _window: Self::Root,
+ sender: ComponentSender<Self>,
+ ) -> ComponentParts<Self> {
+ let gtksink = gst::ElementFactory::make("gtk4paintablesink")
+ .build()
+ .expect("Failed to create gtk4paintablesink");
+
+ // Need to set state to Ready to get a GL context
+ gtksink
+ .set_state(gst::State::Ready)
+ .expect("Failed to set GTK sink state to ready");
+
+ let paintable = gtksink.property::<gdk::Paintable>("paintable");
+
+ let sink = if paintable
+ .property::<Option<gdk::GLContext>>("gl-context")
+ .is_some()
+ {
+ gst::ElementFactory::make("glsinkbin")
+ .property("sink", &gtksink)
+ .build()
+ .expect("Failed to build glsinkbin")
+ } else {
+ gtksink.clone()
+ };
+
+ let renderer = PlayVideoOverlayVideoRenderer::with_sink(&sink);
+
+ let player = Play::new(Some(
+ renderer.clone().upcast::<gst_play::PlayVideoRenderer>(),
+ ));
+
+ let mut config = player.config();
+ config.set_position_update_interval(5);
+ player.set_config(config).unwrap();
+
+ // 100MiB ring buffer to improve seek performance
+ player
+ .pipeline()
+ .set_property("ring-buffer-max-size", 100 * 1024 * 1024 as u64);
+
+ let bus_watch = player
+ .message_bus()
+ .add_watch_local(clone!(
+ #[strong]
+ sender,
+ move |_, message| {
+ let play_message = if let Ok(msg) = PlayMessage::parse(message) {
+ msg
+ } else {
+ return glib::ControlFlow::Continue;
+ };
+
+ match play_message {
+ PlayMessage::Error(error_msg) => {
+ eprintln!("Playback error: {:?}", error_msg.error());
+ if let Some(details) = error_msg.details() {
+ eprintln!("Error details: {:?}", details);
+ }
+ }
+ PlayMessage::PositionUpdated(pos) => {
+ if let Some(position) = pos.position() {
+ sender.input(PlayerMsg::UpdatePosition(position));
+ sender
+ .output(PlayerOutput::PositionUpdate(position))
+ .unwrap();
+ }
+ }
+ PlayMessage::DurationChanged(dur) => {
+ if let Some(duration) = dur.duration() {
+ sender.input(PlayerMsg::UpdateDuration(duration));
+ }
+ }
+ PlayMessage::Buffering(_) => {
+ // TODO
+ }
+ _ => {}
+ }
+
+ glib::ControlFlow::Continue
+ }
+ ))
+ .expect("Failed to add message bus watch");
+
+ let model = Player {
+ gtksink,
+ renderer,
+ player,
+ bus_watch,
+ is_playing: false,
+ duration: gst::ClockTime::ZERO,
+ position: gst::ClockTime::ZERO,
+ seeking: false,
+ };
+
+ let widgets = view_output!();
+
+ // find the existing GestureClick controller in the Scale widget
+ // instead of adding a new one to avoid this bug:
+ // https://gitlab.gnome.org/GNOME/gtk/-/issues/4939
+ let gesture = widgets
+ .seek_scale
+ .observe_controllers()
+ .into_iter()
+ .find_map(|controller| controller.unwrap().downcast::<gtk::GestureClick>().ok())
+ .unwrap();
+
+ gesture.connect_pressed(clone!(
+ #[strong]
+ sender,
+ move |_, _, _, _| {
+ sender.input(PlayerMsg::StartSeeking);
+ }
+ ));
+ gesture.connect_released(clone!(
+ #[strong]
+ sender,
+ move |_, _, _, _| {
+ sender.input(PlayerMsg::StopSeeking);
+ }
+ ));
+
+ ComponentParts { model, widgets }
+ }
+
+ fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
+ match msg {
+ PlayerMsg::SetUrl(url) => {
+ self.player.set_uri(Some(&url));
+ self.play();
+ }
+ PlayerMsg::PlayPause => {
+ if self.is_playing {
+ self.pause();
+ } else {
+ self.play();
+ }
+ }
+ PlayerMsg::Play => self.play(),
+ PlayerMsg::Pause => self.pause(),
+ PlayerMsg::SeekTo(position) => {
+ //self.seek_position = position_ms;
+ self.player.seek(position);
+ self.position = position;
+ }
+ PlayerMsg::StartSeeking => {
+ self.seeking = true;
+ }
+ PlayerMsg::StopSeeking => {
+ self.seeking = false;
+ }
+ PlayerMsg::UpdatePosition(position) => {
+ if !self.seeking {
+ self.position = position;
+ }
+ }
+ PlayerMsg::UpdateDuration(duration) => {
+ self.duration = duration;
+ }
+ }
+ }
+}
+
+impl Player {
+ fn play(&mut self) {
+ self.player.play();
+ self.is_playing = true;
+ }
+
+ fn pause(&mut self) {
+ self.player.pause();
+ self.is_playing = false;
+ }
+}