diff --git a/Cargo.lock b/Cargo.lock index 7558fae30..8f6402214 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1382,18 +1382,18 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 4c52b5f82..52e70c103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ anyhow = "1.0.59" tracing = "0.1.36" tracing-subscriber = "0.3.15" gettext-rs = { version = "0.7.0", features = ["gettext-system"] } -gtk = { package = "gtk4", version = "0.7", features = ["gnome_45"] } +gtk = { package = "gtk4", version = "0.7", features = ["v4_14"] } gdk-wayland = { package = "gdk4-wayland", version = "0.7" } gdk-x11 = { package = "gdk4-x11", version = "0.7" } adw = { package = "libadwaita", version = "0.5", features = ["v1_4"] } diff --git a/build-aux/io.github.seadve.Kooha.Devel.json b/build-aux/io.github.seadve.Kooha.Devel.json index f02d26611..59f022145 100644 --- a/build-aux/io.github.seadve.Kooha.Devel.json +++ b/build-aux/io.github.seadve.Kooha.Devel.json @@ -17,8 +17,7 @@ "--env=RUST_BACKTRACE=1", "--env=RUST_LOG=kooha=debug", "--env=G_MESSAGES_DEBUG=none", - "--env=KOOHA_EXPERIMENTAL=1", - "--env=GST_DEBUG=3" + "--env=KOOHA_EXPERIMENTAL=1" ], "build-options": { "append-path": "/usr/lib/sdk/llvm16/bin:/usr/lib/sdk/rust-stable/bin", diff --git a/data/io.github.seadve.Kooha.gschema.xml.in b/data/io.github.seadve.Kooha.gschema.xml.in index 37621ccaf..64f757beb 100644 --- a/data/io.github.seadve.Kooha.gschema.xml.in +++ b/data/io.github.seadve.Kooha.gschema.xml.in @@ -1,6 +1,21 @@ + + 1000 + Window width + + + + 600 + Window height + + + + false + Whether the window is maximized + + diff --git a/data/resources/icons/scalable/actions/checkmark-symbolic.svg b/data/resources/icons/scalable/actions/checkmark-symbolic.svg deleted file mode 100644 index 2fae34877..000000000 --- a/data/resources/icons/scalable/actions/checkmark-symbolic.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/data/resources/icons/scalable/actions/circle-filled-symbolic.svg b/data/resources/icons/scalable/actions/circle-filled-symbolic.svg new file mode 100644 index 000000000..021946b27 --- /dev/null +++ b/data/resources/icons/scalable/actions/circle-filled-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/crop-symbolic.svg b/data/resources/icons/scalable/actions/crop-symbolic.svg new file mode 100644 index 000000000..fee016932 --- /dev/null +++ b/data/resources/icons/scalable/actions/crop-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/resources/icons/scalable/actions/info-symbolic.svg b/data/resources/icons/scalable/actions/info-symbolic.svg new file mode 100644 index 000000000..65d5d5d10 --- /dev/null +++ b/data/resources/icons/scalable/actions/info-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/resources/icons/scalable/actions/refresh-symbolic.svg b/data/resources/icons/scalable/actions/refresh-symbolic.svg deleted file mode 100644 index 6226b5431..000000000 --- a/data/resources/icons/scalable/actions/refresh-symbolic.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/data/resources/icons/scalable/actions/selection-symbolic.svg b/data/resources/icons/scalable/actions/selection-symbolic.svg deleted file mode 100644 index 80e479081..000000000 --- a/data/resources/icons/scalable/actions/selection-symbolic.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/data/resources/icons/scalable/actions/source-pick-symbolic.svg b/data/resources/icons/scalable/actions/source-pick-symbolic.svg deleted file mode 100644 index 9fb8113b0..000000000 --- a/data/resources/icons/scalable/actions/source-pick-symbolic.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index eeeb094c8..068076843 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -3,19 +3,18 @@ icons/scalable/actions/audio-volume-high-symbolic.svg icons/scalable/actions/audio-volume-muted-symbolic.svg - icons/scalable/actions/checkmark-symbolic.svg + icons/scalable/actions/circle-filled-symbolic.svg + icons/scalable/actions/crop-symbolic.svg + icons/scalable/actions/info-symbolic.svg icons/scalable/actions/microphone-disabled-symbolic.svg icons/scalable/actions/microphone2-symbolic.svg icons/scalable/actions/mouse-wireless-disabled-symbolic.svg icons/scalable/actions/mouse-wireless-symbolic.svg - icons/scalable/actions/refresh-symbolic.svg - icons/scalable/actions/selection-symbolic.svg - icons/scalable/actions/source-pick-symbolic.svg icons/scalable/actions/warning-symbolic.svg style.css - ui/area-selector.ui ui/preferences-window.ui ui/shortcuts.ui + ui/view-port.ui ui/window.ui diff --git a/data/resources/style.css b/data/resources/style.css index 986199d93..696cc0159 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -1,3 +1,11 @@ +.view-port { + padding: 6px; +} + +.red { + color: #e01b24; +} + row.error-view { padding: 0px; } diff --git a/data/resources/ui/area-selector.ui b/data/resources/ui/area-selector.ui deleted file mode 100644 index a262b4fb1..000000000 --- a/data/resources/ui/area-selector.ui +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - diff --git a/data/resources/ui/shortcuts.ui b/data/resources/ui/shortcuts.ui index 903b732dc..84de62469 100644 --- a/data/resources/ui/shortcuts.ui +++ b/data/resources/ui/shortcuts.ui @@ -39,7 +39,7 @@ Recording - win.toggle-record + win.toggle-recording Toggle Record @@ -52,7 +52,7 @@ - win.cancel-record + win.cancel-recording Cancel Record diff --git a/data/resources/ui/view-port.ui b/data/resources/ui/view-port.ui new file mode 100644 index 000000000..17144e822 --- /dev/null +++ b/data/resources/ui/view-port.ui @@ -0,0 +1,22 @@ + + + + diff --git a/data/resources/ui/window.ui b/data/resources/ui/window.ui index 1f1302d04..5176d246d 100644 --- a/data/resources/ui/window.ui +++ b/data/resources/ui/window.ui @@ -1,282 +1,223 @@ - - + +
+ + _Preferences + app.show-preferences + + + _Keyboard Shortcuts + win.show-help-overlay + + + _About Kooha + app.show-about + +
+
- -
- - _Preferences - app.show-preferences - - - _Keyboard Shortcuts - win.show-help-overlay - - - _About Kooha - app.show-about - -
-
+ + + + + +
diff --git a/po/POTFILES.in b/po/POTFILES.in index 38a52158d..11d2cb48d 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,17 +1,14 @@ data/io.github.seadve.Kooha.desktop.in.in data/io.github.seadve.Kooha.gschema.xml.in data/io.github.seadve.Kooha.metainfo.xml.in.in -data/resources/ui/area-selector.ui data/resources/ui/preferences-window.ui data/resources/ui/shortcuts.ui data/resources/ui/window.ui src/about.rs src/application.rs -src/area_selector/mod.rs src/audio_device.rs src/main.rs src/preferences_window.rs src/profile.rs -src/recording.rs src/settings.rs src/window.rs diff --git a/src/about.rs b/src/about.rs index d9b9a13f7..8d9b6b505 100644 --- a/src/about.rs +++ b/src/about.rs @@ -2,7 +2,7 @@ use gettextrs::gettext; use gst::prelude::*; use gtk::{glib, prelude::*}; -use std::env; +use std::{env, path::Path}; use crate::{ config::{APP_ID, VERSION}, @@ -16,7 +16,7 @@ pub fn present_window(transient_for: Option<&impl IsA>) { .application_name(gettext("Kooha")) .developer_name(gettext("Dave Patrick Caberto")) .version(VERSION) - .copyright(gettext("© 2022 Dave Patrick Caberto")) + .copyright(gettext("© 2023 Dave Patrick Caberto")) .license_type(gtk::License::Gpl30) .developers(vec![ "Dave Patrick Caberto", @@ -65,7 +65,7 @@ fn release_notes() -> &'static str { } fn debug_info() -> String { - let is_flatpak = utils::is_flatpak(); + let is_flatpak = Path::new("/.flatpak-info").exists(); let is_experimental_mode = utils::is_experimental_mode(); let language_names = glib::language_names().join(", "); diff --git a/src/application.rs b/src/application.rs index 037aea80d..dd4b2fec1 100644 --- a/src/application.rs +++ b/src/application.rs @@ -144,7 +144,7 @@ impl Application { if !err.matches(gio::IOErrorEnum::Cancelled) { tracing::error!("Failed to launch default for uri `{}`: {:?}", uri, err); - self.window().present_error(&err.into()); + // self.window().present_error(&err.into()); } } } @@ -196,9 +196,7 @@ impl Application { let action_quit = gio::SimpleAction::new("quit", None); action_quit.connect_activate(clone!(@weak self as obj => move |_, _| { if let Some(window) = obj.imp().window.get().and_then(|window| window.upgrade()) { - if let Err(err) = window.close() { - tracing::warn!("Failed to close window: {:?}", err); - } + window.close(); } obj.quit(); })); @@ -212,9 +210,9 @@ impl Application { self.set_accels_for_action("win.record-speaker", &["a"]); self.set_accels_for_action("win.record-mic", &["m"]); self.set_accels_for_action("win.show-pointer", &["p"]); - self.set_accels_for_action("win.toggle-record", &["r"]); + self.set_accels_for_action("win.toggle-recording", &["r"]); // self.set_accels_for_action("win.toggle-pause", &["k"]); // See issue #112 in GitHub repo - self.set_accels_for_action("win.cancel-record", &["c"]); + self.set_accels_for_action("win.cancel-recording", &["c"]); } } diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs deleted file mode 100644 index f55f0a832..000000000 --- a/src/area_selector/mod.rs +++ /dev/null @@ -1,331 +0,0 @@ -mod view_port; - -use adw::{prelude::*, subclass::prelude::*}; -use anyhow::{Context, Result}; -use futures_channel::oneshot::{self, Sender}; -use gettextrs::gettext; -use gst::prelude::*; -use gtk::{ - gdk, - glib::{self, clone}, - graphene::Rect, -}; - -use std::{cell::RefCell, os::unix::prelude::RawFd}; - -use self::view_port::{Selection, ViewPort}; -use crate::{cancelled::Cancelled, pipeline, screencast_session::Stream}; - -const PREVIEW_FRAMERATE: u32 = 60; -const ASSUMED_HEADER_BAR_HEIGHT: f64 = 47.0; - -#[derive(Debug)] -pub struct Data { - /// Selection relative to paintable_rect - pub selection: Selection, - /// The geometry of paintable where the stream is displayed - pub paintable_rect: Rect, - /// Actual stream size - pub stream_size: (i32, i32), -} - -mod imp { - use std::cell::OnceCell; - - use super::*; - use gst::bus::BusWatchGuard; - use gtk::CompositeTemplate; - - #[derive(Debug, Default, CompositeTemplate)] - #[template(resource = "/io/github/seadve/Kooha/ui/area-selector.ui")] - pub struct AreaSelector { - #[template_child] - pub(super) window_title: TemplateChild, - #[template_child] - pub(super) done_button: TemplateChild, - #[template_child] - pub(super) stack: TemplateChild, - #[template_child] - pub(super) loading: TemplateChild, - #[template_child] - pub(super) view_port: TemplateChild, - - pub(super) pipeline: OnceCell, - pub(super) stream_size: OnceCell<(i32, i32)>, - pub(super) result_tx: RefCell>>>, - pub(super) async_done_tx: RefCell>>>, - pub(super) bus_watch_guard: OnceCell, - } - - #[glib::object_subclass] - impl ObjectSubclass for AreaSelector { - const NAME: &'static str = "KoohaAreaSelector"; - type Type = super::AreaSelector; - type ParentType = adw::Window; - - fn class_init(klass: &mut Self::Class) { - klass.bind_template(); - - klass.install_action("area-selector.cancel", None, move |obj, _, _| { - if let Some(sender) = obj.imp().async_done_tx.take() { - let _ = sender.send(Err(Cancelled::new("area select loading"))); - } - - if let Some(sender) = obj.imp().result_tx.take() { - let _ = sender.send(Err(Cancelled::new("area select"))); - obj.close(); - } else { - tracing::error!("Sent result twice"); - } - }); - - klass.install_action("area-selector.done", None, move |obj, _, _| { - if let Some(sender) = obj.imp().result_tx.take() { - let _ = sender.send(Ok(())); - obj.close(); - } else { - tracing::error!("Sent response twice"); - } - }); - - klass.install_action("area-selector.reset", None, move |obj, _, _| { - obj.imp().view_port.reset_selection(); - }); - - klass.add_binding_action( - gdk::Key::Escape, - gdk::ModifierType::empty(), - "area-selector.cancel", - None, - ); - } - - fn instance_init(obj: &glib::subclass::InitializingObject) { - obj.init_template(); - } - } - - impl ObjectImpl for AreaSelector { - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - - self.view_port - .connect_selection_notify(clone!(@weak obj => move |_| { - obj.update_selection_ui(); - })); - - let done_button = self.done_button.get(); - obj.set_default_widget(Some(&done_button)); - obj.set_focus_widget(Some(&done_button)); - - obj.update_selection_ui(); - } - - fn dispose(&self) { - if let Some(pipeline) = self.pipeline.get() { - if let Err(err) = pipeline.set_state(gst::State::Null) { - tracing::warn!("Failed to set pipeline to Null: {}", err); - } - } - } - } - - impl WidgetImpl for AreaSelector {} - impl WindowImpl for AreaSelector {} - impl AdwWindowImpl for AreaSelector {} -} - -glib::wrapper! { - pub struct AreaSelector(ObjectSubclass) - @extends gtk::Widget, gtk::Window, adw::Window, - @implements gtk::Native; -} - -impl AreaSelector { - pub async fn present( - transient_for: Option<&impl IsA>, - fd: RawFd, - streams: &[Stream], - ) -> Result { - let this: Self = glib::Object::builder().build(); - let imp = this.imp(); - - // Setup window size and transient for - if let Some(transient_for) = transient_for { - let transient_for = transient_for.as_ref(); - - this.set_transient_for(Some(transient_for)); - this.set_modal(true); - - let scale_factor = 0.4 / transient_for.scale_factor() as f64; - let monitor_geometry = RootExt::display(transient_for) - .monitor_at_surface(&transient_for.surface()) - .context("No monitor found")? - .geometry(); - this.set_default_width( - (monitor_geometry.width() as f64 * scale_factor - ASSUMED_HEADER_BAR_HEIGHT * 2.0) - as i32, - ); - this.set_default_height((monitor_geometry.height() as f64 * scale_factor) as i32); - } - - imp.stack.set_visible_child(&imp.loading.get()); - - let (result_tx, result_rx) = oneshot::channel(); - imp.result_tx.replace(Some(result_tx)); - - // Setup pipeline - let pipeline = gst::Pipeline::new(); - let videosrc_bin = pipeline::pipewiresrc_bin(fd, streams, PREVIEW_FRAMERATE, None)?; - let sink = gst::ElementFactory::make("gtk4paintablesink").build()?; - pipeline.add_many([videosrc_bin.upcast_ref(), &sink])?; - videosrc_bin.link(&sink)?; - imp.pipeline.set(pipeline.clone()).unwrap(); - - // Setup paintable - let paintable = sink.property::("paintable"); - imp.view_port.set_paintable(Some(paintable)); - - pipeline.set_state(gst::State::Playing)?; - - let (async_done_tx, async_done_rx) = oneshot::channel(); - imp.async_done_tx.replace(Some(async_done_tx)); - - // Setup bus to receive async done message - let bus_watch_guard = pipeline - .bus() - .unwrap() - .add_watch_local( - clone!(@weak this as obj => @default-return glib::ControlFlow::Break, move |_, message| { - obj.handle_bus_message(message) - }), - ) - .unwrap(); - imp.bus_watch_guard.set(bus_watch_guard).unwrap(); - - this.present(); - - // Wait for pipeline to be on playing state - async_done_rx.await.unwrap()?; - - imp.stack.set_visible_child(&imp.view_port.get()); - - // Get stream size - let caps = videosrc_bin - .static_pad("src") - .context("Videosrc bin has no src pad")? - .current_caps() - .context("Videosrc bin src pad has no currentcaps")?; - let caps_struct = caps - .structure(0) - .context("Videosrc bin src pad caps has no structure")?; - let stream_width = caps_struct.get::("width")?; - let stream_height = caps_struct.get::("height")?; - imp.stream_size.set((stream_width, stream_height)).unwrap(); - this.update_selection_ui(); - - // Wait for user response - result_rx.await.unwrap()?; - - Ok(Data { - selection: imp.view_port.selection().unwrap(), - paintable_rect: imp.view_port.paintable_rect().unwrap(), - stream_size: (stream_width, stream_height), - }) - } - - fn handle_bus_message(&self, message: &gst::Message) -> glib::ControlFlow { - use gst::MessageView; - - let imp = self.imp(); - - match message.view() { - MessageView::AsyncDone(_) => { - if let Some(async_done_tx) = imp.async_done_tx.take() { - let _ = async_done_tx.send(Ok(())); - } - - glib::ControlFlow::Continue - } - MessageView::Eos(_) => { - tracing::debug!("Eos signal received from record bus"); - - glib::ControlFlow::Break - } - MessageView::StateChanged(sc) => { - let new_state = sc.current(); - - if message.src() - != imp - .pipeline - .get() - .map(|pipeline| pipeline.upcast_ref::()) - { - tracing::trace!( - "`{}` changed state from `{:?}` -> `{:?}`", - message - .src() - .map_or_else(|| "".into(), |e| e.name()), - sc.old(), - new_state, - ); - return glib::ControlFlow::Continue; - } - - tracing::debug!( - "Pipeline changed state from `{:?}` -> `{:?}`", - sc.old(), - new_state, - ); - - glib::ControlFlow::Continue - } - MessageView::Error(e) => { - tracing::error!("Received error message on bus: {:?}", e); - glib::ControlFlow::Break - } - MessageView::Warning(w) => { - tracing::warn!("Received warning message on bus: {:?}", w); - glib::ControlFlow::Continue - } - MessageView::Info(i) => { - tracing::debug!("Received info message on bus: {:?}", i); - glib::ControlFlow::Continue - } - other => { - tracing::trace!("Received other message on bus: {:?}", other); - glib::ControlFlow::Continue - } - } - } - - fn update_selection_ui(&self) { - let imp = self.imp(); - let view_port = imp.view_port.get(); - - let selection = view_port.selection(); - - self.action_set_enabled("area-selector.reset", selection.is_some()); - self.action_set_enabled("area-selector.done", selection.is_some()); - - if let (Some(stream_size), Some(selection)) = (imp.stream_size.get(), selection) { - let paintable_rect = view_port.paintable_rect().unwrap(); - - let (stream_width, stream_height) = stream_size; - let scale_factor_h = *stream_width as f32 / paintable_rect.width(); - let scale_factor_v = *stream_height as f32 / paintable_rect.height(); - - let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); - imp.window_title.set_subtitle(&format!( - "{} {}×{}", - gettext("approx."), - selection_rect_scaled.width().round() as i32, - selection_rect_scaled.height().round() as i32, - )); - } else { - imp.window_title.set_subtitle(""); - } - } -} diff --git a/src/i18n.rs b/src/i18n.rs deleted file mode 100644 index f261bc600..000000000 --- a/src/i18n.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copied from Fractal GPLv3 -// See https://gitlab.gnome.org/GNOME/fractal/-/blob/c0bc4078bb2cdd511c89fdf41a51275db90bb7ab/src/i18n.rs - -use gettextrs::gettext; - -/// Like `gettext`, but replaces named variables using the given key-value tuples. -/// -/// The expected format to replace is `{name}`, where `name` is the first string -/// in a key-value tuple. -pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String { - let s = gettext(msgid); - freplace(s, args) -} - -/// Replace variables in the given string using the given key-value tuples. -/// -/// The expected format to replace is `{name}`, where `name` is the first string -/// in a key-value tuple. -fn freplace(s: String, args: &[(&str, &str)]) -> String { - // This function is useless if there are no arguments - debug_assert!(!args.is_empty(), "atleast one key-value pair must be given"); - - // We could check here if all keys were used, but some translations might - // not use all variables, so we don't do that. - - let mut s = s; - for (key, val) in args { - s = s.replace(&format!("{{{key}}}"), val); - } - - debug_assert!(!s.contains('{'), "all format variables must be replaced"); - - if tracing::enabled!(tracing::Level::WARN) && s.contains('{') { - tracing::warn!( - "all format variables must be replaced, but some were not: {}", - s - ); - } - - s -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - #[should_panic = "atleast one key-value pair must be given"] - fn freplace_no_args() { - gettext_f("no args", &[]); - } - - #[test] - #[should_panic = "all format variables must be replaced"] - fn freplace_missing_key() { - gettext_f("missing {one}", &[("two", "2")]); - } - - #[test] - fn gettext_f_simple() { - assert_eq!(gettext_f("no replace", &[("one", "1")]), "no replace"); - assert_eq!(gettext_f("{one} param", &[("one", "1")]), "1 param"); - assert_eq!( - gettext_f("middle {one} param", &[("one", "1")]), - "middle 1 param" - ); - assert_eq!(gettext_f("end {one}", &[("one", "1")]), "end 1"); - } - - #[test] - fn gettext_f_multiple() { - assert_eq!( - gettext_f("multiple {one} and {two}", &[("one", "1"), ("two", "2")]), - "multiple 1 and 2" - ); - assert_eq!( - gettext_f("multiple {two} and {one}", &[("one", "1"), ("two", "2")]), - "multiple 2 and 1" - ); - assert_eq!( - gettext_f("multiple {one} and {one}", &[("one", "1"), ("two", "2")]), - "multiple 1 and 1" - ); - } -} diff --git a/src/main.rs b/src/main.rs index c05eef050..31ee82b4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,22 +25,20 @@ mod about; mod application; -mod area_selector; mod audio_device; mod cancelled; mod config; mod element_properties; mod help; -mod i18n; mod pipeline; mod preferences_window; mod profile; -mod recording; mod screencast_session; mod settings; mod timer; mod toggle_button; mod utils; +mod view_port; mod window; use gettextrs::{gettext, LocaleCategory}; diff --git a/src/pipeline.rs b/src/pipeline.rs index 403e2c19d..ffbfa0a4e 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,431 +1,770 @@ -use anyhow::{ensure, Context, Ok, Result}; -use gst::prelude::*; -use gtk::{glib, graphene::Rect}; +use std::{os::fd::RawFd, path::Path, time::Duration}; -use std::{ - ffi::OsStr, - os::unix::io::RawFd, - path::{Path, PathBuf}, +use anyhow::{ensure, Context, Result}; +use gst::prelude::*; +use gst_pbutils::prelude::*; +use gtk::{ + gdk, + glib::{self, clone, closure_local}, + graphene::Rect, + subclass::prelude::*, }; use crate::{ - area_selector::Data as SelectAreaData, profile::Profile, screencast_session::Stream, utils, + audio_device::{self, Class as AudioDeviceClass}, + screencast_session::Stream, }; -// TODO -// * Do we need restrictions? -// * Can we drop filter elements (videorate, videoconvert, videoscale, audioconvert) and let encodebin handle it? -// * Can we set frame rate directly on profile format? -// * Add tests - -const DEFAULT_AUDIO_SAMPLE_RATE: i32 = 48_000; - -#[derive(Debug)] -#[must_use] -pub struct PipelineBuilder { - saving_location: PathBuf, - framerate: u32, - profile: Box, - fd: RawFd, - streams: Vec, - speaker_source: Option, - mic_source: Option, - select_area_data: Option, +const DURATION_UPDATE_INTERVAL: Duration = Duration::from_millis(200); +const PREVIEW_FRAME_RATE: i32 = 60; + +const COMPOSITOR_NAME: &str = "compositor"; +const VIDEO_TEE_NAME: &str = "video-tee"; +const PAINTABLE_SINK_NAME: &str = "paintablesink"; + +const DESKTOP_AUDIO_LEVEL_NAME: &str = "desktop-audio-level"; +const DESKTOP_AUDIO_TEE: &str = "desktop-audio-tee"; + +const MICROPHONE_LEVEL_NAME: &str = "microphone-level"; +const MICROPHONE_TEE: &str = "microphone-tee"; + +pub struct CropData { + /// Full rect where the selection is made. + pub full_rect: Rect, + /// Selection made from the full rect. + pub selection_rect: Rect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Boxed)] +#[boxed_type(name = "KoohaStreamSize", nullable)] +pub struct StreamSize { + width: i32, + height: i32, +} + +impl StreamSize { + pub fn new(width: i32, height: i32) -> Self { + Self { width, height } + } + + pub fn width(self) -> i32 { + self.width + } + + pub fn height(self) -> i32 { + self.height + } +} + +#[derive(Debug, Clone, Copy, glib::Boxed)] +#[boxed_type(name = "KoohaPeaks")] +pub struct Peaks { + left: f64, + right: f64, +} + +impl Peaks { + pub fn new(left: f64, right: f64) -> Self { + Self { left, right } + } + + pub fn left(&self) -> f64 { + self.left + } + + pub fn right(&self) -> f64 { + self.right + } } -impl PipelineBuilder { - pub fn new( - saving_location: &Path, - framerate: u32, - profile: Box, - fd: RawFd, - streams: Vec, - ) -> Self { - Self { - saving_location: saving_location.to_path_buf(), - framerate, - profile, - fd, - streams, - speaker_source: None, - mic_source: None, - select_area_data: None, +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Boxed)] +#[boxed_type(name = "KoohaRecordingState")] +pub enum RecordingState { + #[default] + Idle, + Started { + duration: gst::ClockTime, + }, +} + +impl RecordingState { + pub fn started(duration: gst::ClockTime) -> Self { + Self::Started { duration } + } + + pub fn is_started(self) -> bool { + matches!(self, Self::Started { .. }) + } + + pub fn is_idle(self) -> bool { + matches!(self, Self::Idle) + } +} + +mod imp { + use std::cell::{Cell, RefCell}; + + use glib::{once_cell::sync::Lazy, subclass::Signal}; + use gst::bus::BusWatchGuard; + + use super::*; + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::Pipeline)] + pub struct Pipeline { + #[property(get)] + pub(super) stream_size: Cell>, + #[property(get)] + pub(super) recording_state: Cell, + + pub(super) inner: gst::Pipeline, + pub(super) bus_watch_guard: RefCell>, + + pub(super) video_elements: RefCell>, + pub(super) desktop_audio_elements: RefCell>, + pub(super) microphone_elements: RefCell>, + pub(super) recording_elements: RefCell>, + + pub(super) duration_source_id: RefCell>, + pub(super) caps_notify_source_id: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Pipeline { + const NAME: &'static str = "KoohaPipeline"; + type Type = super::Pipeline; + } + + #[glib::derived_properties] + impl ObjectImpl for Pipeline { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + + if let Err(err) = obj.setup_elements() { + tracing::error!("Failed to setup pipeline: {:?}", err); + } + } + + fn dispose(&self) { + if let Err(err) = self.inner.set_state(gst::State::Null) { + tracing::error!("Failed to set state to Null {:?}", err); + } + + if let Some(source_id) = self.duration_source_id.take() { + source_id.remove(); + } + + if let Some(source_id) = self.caps_notify_source_id.take() { + source_id.remove(); + } + + let _ = self.bus_watch_guard.take(); } + + fn signals() -> &'static [glib::subclass::Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![ + Signal::builder("desktop-audio-peak") + .param_types([Peaks::static_type()]) + .build(), + Signal::builder("microphone-peak") + .param_types([Peaks::static_type()]) + .build(), + ] + }); + + SIGNALS.as_ref() + } + } +} + +glib::wrapper! { + pub struct Pipeline(ObjectSubclass); +} + +impl Pipeline { + pub fn new() -> Self { + glib::Object::new() } - pub fn speaker_source(&mut self, speaker_source: String) -> &mut Self { - self.speaker_source = Some(speaker_source); - self + pub fn connect_desktop_audio_peak(&self, f: F) -> glib::SignalHandlerId + where + F: Fn(&Self, &Peaks) + 'static, + { + self.connect_closure( + "desktop-audio-peak", + false, + closure_local!(|obj: &Self, peaks: &Peaks| { + f(obj, peaks); + }), + ) } - pub fn mic_source(&mut self, mic_source: String) -> &mut Self { - self.mic_source = Some(mic_source); - self + pub fn connect_microphone_peak(&self, f: F) -> glib::SignalHandlerId + where + F: Fn(&Self, &Peaks) + 'static, + { + self.connect_closure( + "microphone-peak", + false, + closure_local!(|obj: &Self, peaks: &Peaks| { + f(obj, peaks); + }), + ) } - pub fn select_area_data(&mut self, data: SelectAreaData) -> &mut Self { - self.select_area_data = Some(data); - self + pub fn paintable(&self) -> gdk::Paintable { + self.imp() + .inner + .by_name(PAINTABLE_SINK_NAME) + .unwrap() + .property("paintable") } - pub fn build(&self) -> Result { - let file_path = new_recording_path(&self.saving_location, self.profile.file_extension()); + pub fn start_recording(&self, dir: &Path, crop_data: Option) -> Result<()> { + let imp = self.imp(); + + ensure!(imp.recording_state.get().is_idle(), "Already recording"); - let queue = gst::ElementFactory::make("queue") - .name("sinkqueue") + assert!(imp.recording_elements.borrow().is_empty()); + + let video_profile = + gst_pbutils::EncodingVideoProfile::builder(&gst::Caps::builder("video/x-vp8").build()) + .preset("Profile Realtime") + .variable_framerate(true) + .build(); + let audio_profile = gst_pbutils::EncodingAudioProfile::builder( + &gst::Caps::builder("audio/x-vorbis").build(), + ) + .build(); + let profile = gst_pbutils::EncodingContainerProfile::builder( + &gst::Caps::builder("video/webm").build(), + ) + .name("WebM audio/video") + .description("Standard WebM/VP8/Vorbis") + .add_profile(video_profile) + .add_profile(audio_profile) + .build(); + + let recording_path = { + let file_name = glib::DateTime::now_local() + .context("Failed to get current time")? + .format("Kooha-%F-%H-%M-%S") + .unwrap(); + let mut path = dir.join(file_name); + path.set_extension("webm"); + path + }; + + let encodebin = gst::ElementFactory::make("encodebin") + .property("profile", profile) .build()?; let filesink = gst::ElementFactory::make("filesink") - .name("filesink") + .property("async", false) // FIXME ? .property( "location", - file_path + recording_path .to_str() - .context("Could not convert file path to string")?, + .context("Cannot convert path to str")?, ) .build()?; - let pipeline = gst::Pipeline::new(); - pipeline.add_many([&queue, &filesink])?; - queue.link(&filesink)?; - - tracing::debug!( - file_path = %file_path.display(), - framerate = self.framerate, - profile = ?self.profile, - stream_len = self.streams.len(), - streams = ?self.streams, - speaker_source = ?self.speaker_source, - mic_source = ?self.mic_source, - select_area_data = ?self.select_area_data, - ); + let elements = vec![encodebin.clone(), filesink.clone()]; + imp.inner.add_many(&elements)?; + encodebin.link(&filesink)?; - ensure!(!self.streams.is_empty(), "No streams provided"); + let video_tee = imp.inner.by_name(VIDEO_TEE_NAME).unwrap(); + let video_tee_src_pad = video_tee + .request_pad_simple("src_%u") + .context("Failed to request src_%u pad from video tee")?; + let encodebin_sink_pad = encodebin + .request_pad_simple("video_%u") + .context("Failed to request video_%u pad from encodebin")?; - let videosrc_bin = pipewiresrc_bin( - self.fd, - &self.streams, - self.framerate, - self.select_area_data.as_ref(), - ) - .context("Failed to create videosrc bin")?; + if let Some(crop_data) = crop_data { + let stream_size = imp.stream_size.get().context("Stream size was not set")?; + let videoscale = gst::ElementFactory::make("videoscale").build()?; + let videocrop = videocrop_compute(&crop_data, stream_size)?; - pipeline.add(&videosrc_bin)?; + // x264enc requires even resolution. + let videoscale_filter = gst::Caps::builder("video/x-raw") + .field("width", round_to_even(stream_size.width())) + .field("height", round_to_even(stream_size.height())) + .build(); - let audiosrc_bin = if self.profile.supports_audio() - && (self.speaker_source.is_some() || self.mic_source.is_some()) - { - let audiosrc_bin = pulsesrc_bin( - [&self.speaker_source, &self.mic_source] - .into_iter() - .filter_map(|s| s.as_deref()), - ) - .context("Failed to create audiosrc bin")?; - pipeline.add(&audiosrc_bin)?; + let elements = vec![videoscale.clone(), videocrop.clone()]; + imp.inner.add_many(&elements)?; - Some(audiosrc_bin) - } else { - if self.speaker_source.is_some() || self.mic_source.is_some() { - tracing::warn!( - "Selected profiles does not support audio, but audio sources are provided. Ignoring" - ); + video_tee_src_pad.link(&videoscale.static_pad("sink").unwrap())?; + + videoscale.link_filtered(&videocrop, &videoscale_filter)?; + + videocrop + .static_pad("src") + .unwrap() + .link(&encodebin_sink_pad)?; + + for element in &elements { + element.sync_state_with_parent()?; } + } else { + video_tee_src_pad.link(&encodebin_sink_pad)?; + } - None - }; + if let Some(desktop_audio_tee) = imp.inner.by_name(DESKTOP_AUDIO_TEE) { + let desktop_audio_tee_src_pad = desktop_audio_tee + .request_pad_simple("src_%u") + .context("Failed to request src_%u pad from desktop audio tee")?; + let encodebin_sink_pad = encodebin + .request_pad_simple("audio_%u") + .context("Failed to request audio_%u pad from encodebin")?; + desktop_audio_tee_src_pad.link(&encodebin_sink_pad)?; + } + + if let Some(microphone_tee) = imp.inner.by_name(MICROPHONE_TEE) { + let microphone_tee_src_pad = microphone_tee + .request_pad_simple("src_%u") + .context("Failed to request src_%u pad from microphone tee")?; + let encodebin_sink_pad = encodebin + .request_pad_simple("audio_%u") + .context("Failed to request audio_%u pad from encodebin")?; + microphone_tee_src_pad.link(&encodebin_sink_pad)?; + } + + for element in &elements { + element.sync_state_with_parent()?; + } - self.profile - .attach( - &pipeline, - videosrc_bin.upcast_ref(), - audiosrc_bin.as_ref().map(|a| a.upcast_ref()), - &queue, + imp.recording_elements.replace(elements); + + imp.duration_source_id.replace(Some(glib::timeout_add_local( + DURATION_UPDATE_INTERVAL, + clone!(@weak self as obj => @default-panic, move || { + let position = obj + .imp() + .inner + .query_position::() + .unwrap_or(gst::ClockTime::ZERO); + obj.set_recording_state(RecordingState::started(position)); + glib::ControlFlow::Continue + }), + ))); + + self.set_recording_state(RecordingState::started(gst::ClockTime::ZERO)); + + if tracing::enabled!(tracing::Level::DEBUG) { + std::fs::write( + glib::DateTime::now_local() + .unwrap() + .format("kooha-%F-%H-%M-%S.dot") + .unwrap(), + gst::debug_bin_to_dot_data(&imp.inner, gst::DebugGraphDetails::VERBOSE), ) - .context("Failed to attach profile to pipeline")?; + .unwrap(); + } + + tracing::debug!("Started recording"); - Ok(pipeline) + Ok(()) } -} -fn pipewiresrc_with_default(fd: RawFd, path: &str) -> Result { - // Workaround copied from https://gitlab.gnome.org/GNOME/gnome-shell/-/commit/d32c03488fcf6cdb0ca2e99b0ed6ade078460deb - let registry = gst::Registry::get(); - let needs_copy = registry.check_feature_version("pipewiresrc", 0, 3, 57) - && !registry.check_feature_version("videoconvert", 1, 20, 4); - - tracing::debug!("pipewiresrc needs copy: {}", needs_copy); - - let src = gst::ElementFactory::make("pipewiresrc") - .property("fd", fd) - .property("path", path) - .property("do-timestamp", true) - .property("keepalive-time", 1000) - .property("resend-last", true) - .property("always-copy", needs_copy) - .build()?; + pub fn stop_recording(&self) -> Result<()> { + let imp = self.imp(); - Ok(src) -} + let recording_elements = imp.recording_elements.take(); -fn videoconvert_with_default() -> Result { - let conv = gst::ElementFactory::make("videoconvert") - .property("chroma-mode", gst_video::VideoChromaMode::None) - .property("dither", gst_video::VideoDitherMethod::None) - .property("matrix-mode", gst_video::VideoMatrixMode::OutputOnly) - .property("n-threads", utils::ideal_thread_count()) - .build()?; - Ok(conv) -} + ensure!(imp.recording_state.get().is_started(), "Not recording"); -/// Create a videocrop element that computes the crop from the given coordinates -/// and size. -fn videocrop_compute(data: &SelectAreaData) -> Result { - let SelectAreaData { - selection, - paintable_rect, - stream_size, - } = data; - - let (stream_width, stream_height) = stream_size; - let scale_factor_h = *stream_width as f32 / paintable_rect.width(); - let scale_factor_v = *stream_height as f32 / paintable_rect.height(); - - if scale_factor_h != scale_factor_v { - tracing::warn!( - scale_factor_h, - scale_factor_v, - "Scale factors of horizontal and vertical are unequal" - ); + assert!(!recording_elements.is_empty()); + + for element in recording_elements { + element.set_state(gst::State::Null)?; + imp.inner.remove(&element)?; + } + + imp.duration_source_id.take().unwrap().remove(); + + self.set_recording_state(RecordingState::Idle); + + tracing::debug!("Stopped recording"); + + Ok(()) } - // Both paintable and selection position are relative to the widget coordinates. - // To get the absolute position and so correct crop values, subtract the paintable - // rect's position from the selection rect. - let old_selection_rect = selection.rect(); - let selection_rect_scaled = Rect::new( - old_selection_rect.x() - paintable_rect.x(), - old_selection_rect.y() - paintable_rect.y(), - old_selection_rect.width(), - old_selection_rect.height(), - ) - .scale(scale_factor_h, scale_factor_v); - - let raw_top_crop = selection_rect_scaled.y(); - let raw_left_crop = selection_rect_scaled.x(); - let raw_right_crop = - *stream_width as f32 - (selection_rect_scaled.width() + selection_rect_scaled.x()); - let raw_bottom_crop = - *stream_height as f32 - (selection_rect_scaled.height() + selection_rect_scaled.y()); - - tracing::debug!(raw_top_crop, raw_left_crop, raw_right_crop, raw_bottom_crop); - - let top_crop = round_to_even_f32(raw_top_crop).clamp(0, *stream_height); - let left_crop = round_to_even_f32(raw_left_crop).clamp(0, *stream_width); - let right_crop = round_to_even_f32(raw_right_crop).clamp(0, *stream_width); - let bottom_crop = round_to_even_f32(raw_bottom_crop).clamp(0, *stream_height); - - tracing::debug!(top_crop, left_crop, right_crop, bottom_crop); + pub fn set_streams(&self, streams: &[Stream], fd: RawFd) -> Result<()> { + let imp = self.imp(); - // x264enc requires even resolution. - let crop = gst::ElementFactory::make("videocrop") - .property("top", top_crop) - .property("left", left_crop) - .property("right", right_crop) - .property("bottom", bottom_crop) - .build()?; - Ok(crop) -} + for element in imp.video_elements.take() { + element.set_state(gst::State::Null)?; + imp.inner.remove(&element)?; + } + + let compositor = imp.inner.by_name(COMPOSITOR_NAME).unwrap(); + + for pad in compositor.sink_pads() { + compositor.release_request_pad(&pad); + } -/// Creates a bin with a src pad for multiple pipewire streams. -/// (If has select area data) -/// pipewiresrc1 -> videorate -> | | | -/// | V V -/// pipewiresrc2 -> videorate -> | -> compositor -> videoconvert -> videoscale -> videocrop -> queue -/// | -/// pipewiresrcn -> videorate -> | -pub fn pipewiresrc_bin( - fd: RawFd, - streams: &[Stream], - framerate: u32, - select_area_data: Option<&SelectAreaData>, -) -> Result { - let bin = gst::Bin::new(); - - let compositor = gst::ElementFactory::make("compositor").build()?; - let videoconvert = videoconvert_with_default()?; - let queue = gst::ElementFactory::make("queue").build()?; - - bin.add_many([&compositor, &videoconvert, &queue])?; - compositor.link(&videoconvert)?; - - if let Some(data) = select_area_data { - let videoscale = gst::ElementFactory::make("videoscale").build()?; - let videocrop = videocrop_compute(data)?; - - // x264enc requires even resolution. - let (stream_width, stream_height) = data.stream_size; - let videoscale_filter = gst::Caps::builder("video/x-raw") - .field("width", round_to_even(stream_width)) - .field("height", round_to_even(stream_height)) + let videorate_caps = gst::Caps::builder("video/x-raw") + .field("framerate", gst::Fraction::new(PREVIEW_FRAME_RATE, 1)) .build(); - bin.add_many([&videoscale, &videocrop])?; - videoconvert.link(&videoscale)?; - videoscale.link_filtered(&videocrop, &videoscale_filter)?; - videocrop.link(&queue)?; - } else { - videoconvert.link(&queue)?; + let mut last_pos = 0; + for stream in streams { + let pipewiresrc = gst::ElementFactory::make("pipewiresrc") + .property("fd", fd) + .property("path", stream.node_id().to_string()) + .property("do-timestamp", true) + .property("keepalive-time", 1000) + .property("resend-last", true) + .build()?; + let videorate = gst::ElementFactory::make("videorate").build()?; + let videorate_capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &videorate_caps) + .build()?; + + let elements = [pipewiresrc, videorate, videorate_capsfilter.clone()]; + imp.inner.add_many(&elements)?; + gst::Element::link_many(&elements)?; + imp.video_elements.borrow_mut().extend(elements); + + let compositor_sink_pad = compositor + .request_pad_simple("sink_%u") + .context("Failed to request sink_%u pad from compositor")?; + compositor_sink_pad.set_property("xpos", last_pos); + videorate_capsfilter + .static_pad("src") + .unwrap() + .link(&compositor_sink_pad)?; + + let (stream_width, _) = stream.size().context("stream is missing size")?; + last_pos += stream_width; + } + + for element in imp.video_elements.borrow().iter() { + element.sync_state_with_parent()?; + } + + tracing::debug!("Loaded {} streams", streams.len()); + + match imp.inner.set_state(gst::State::Playing)? { + gst::StateChangeSuccess::Success | gst::StateChangeSuccess::NoPreroll => { + self.update_stream_size(); + } + gst::StateChangeSuccess::Async => {} + } + + Ok(()) } - let videorate_filter = gst::Caps::builder("video/x-raw") - .field("framerate", gst::Fraction::new(framerate as i32, 1)) - .build(); + pub async fn load_desktop_audio(&self) -> Result<()> { + let imp = self.imp(); - let mut last_pos = 0; - for stream in streams { - let pipewiresrc = pipewiresrc_with_default(fd, &stream.node_id().to_string())?; - let videorate = gst::ElementFactory::make("videorate").build()?; - let videorate_capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &videorate_filter) + if !imp.desktop_audio_elements.borrow().is_empty() { + return Ok(()); + } + + let device_name = audio_device::find_default_name(AudioDeviceClass::Sink) + .await + .context("No desktop audio source found")?; + + let pulsesrc = gst::ElementFactory::make("pulsesrc") + .property("device", &device_name) + .build()?; + let audioconvert = gst::ElementFactory::make("audioconvert").build()?; + let level = gst::ElementFactory::make("level") + .name(DESKTOP_AUDIO_LEVEL_NAME) + .property("interval", gst::ClockTime::from_mseconds(80)) + .property("peak-ttl", gst::ClockTime::from_mseconds(80)) + .build()?; + let tee = gst::ElementFactory::make("tee") + .name(DESKTOP_AUDIO_TEE) + .build()?; + let fakesink = gst::ElementFactory::make("fakesink") + .property("sync", true) .build()?; - bin.add_many([&pipewiresrc, &videorate, &videorate_capsfilter])?; - gst::Element::link_many([&pipewiresrc, &videorate, &videorate_capsfilter])?; + let elements = vec![pulsesrc, audioconvert, level, tee, fakesink]; + imp.inner.add_many(&elements)?; + gst::Element::link_many(&elements)?; - let compositor_sink_pad = compositor - .request_pad_simple("sink_%u") - .context("Failed to request sink_%u pad from compositor")?; - compositor_sink_pad.set_property("xpos", last_pos); - videorate_capsfilter - .static_pad("src") - .unwrap() - .link(&compositor_sink_pad)?; + for element in &elements { + element.sync_state_with_parent()?; + } - let stream_width = stream.size().unwrap().0; - last_pos += stream_width; + imp.desktop_audio_elements.replace(elements); + + tracing::debug!("Loaded desktop audio from {}", device_name); + + Ok(()) } - let queue_pad = queue.static_pad("src").unwrap(); - bin.add_pad( - &gst::GhostPad::builder_with_target(&queue_pad)? - .name("src") - .build(), - )?; + pub fn unload_desktop_audio(&self) -> Result<()> { + let imp = self.imp(); - Ok(bin) -} + for element in imp.desktop_audio_elements.take() { + element.set_state(gst::State::Null)?; + imp.inner.remove(&element)?; + } -/// Creates a bin with a src pad for a pulse audio device -/// -/// pulsesrc1 -> audioresample -> | -/// | -/// pulsesrc2 -> audioresample -> | -> audiomixer -> audiorate -> audioconvert -> queue -/// | -/// pulsesrcn -> audioresample -> | -fn pulsesrc_bin<'a>(device_names: impl IntoIterator) -> Result { - let bin = gst::Bin::new(); - - let audiomixer = gst::ElementFactory::make("audiomixer").build()?; - let audiorate = gst::ElementFactory::make("audiorate").build()?; - let audioconvert = gst::ElementFactory::make("audioconvert").build()?; - let queue = gst::ElementFactory::make("queue").build()?; - - let sample_rate_filter = gst::Caps::builder("audio/x-raw") - .field("rate", DEFAULT_AUDIO_SAMPLE_RATE) - .build(); + tracing::debug!("Unloaded desktop audio"); + + Ok(()) + } - bin.add_many([&audiomixer, &audiorate, &audioconvert, &queue])?; - audiomixer.link_filtered(&audiorate, &sample_rate_filter)?; - gst::Element::link_many([&audiorate, &audioconvert, &queue])?; + pub async fn load_microphone(&self) -> Result<()> { + let imp = self.imp(); + + if !imp.microphone_elements.borrow().is_empty() { + return Ok(()); + } + + let device_name = audio_device::find_default_name(AudioDeviceClass::Source) + .await + .context("No desktop audio source found")?; - for device_name in device_names { let pulsesrc = gst::ElementFactory::make("pulsesrc") - .property("device", device_name) - .property("provide-clock", false) + .property("device", &device_name) + .build()?; + let audioconvert = gst::ElementFactory::make("audioconvert").build()?; + let level = gst::ElementFactory::make("level") + .name(MICROPHONE_LEVEL_NAME) + .property("interval", gst::ClockTime::from_mseconds(80)) + .property("peak-ttl", gst::ClockTime::from_mseconds(80)) .build()?; - let audioresample = gst::ElementFactory::make("audioresample").build()?; - let capsfilter = gst::ElementFactory::make("capsfilter") - .property("caps", &sample_rate_filter) + let tee = gst::ElementFactory::make("tee") + .name(MICROPHONE_TEE) + .build()?; + let fakesink = gst::ElementFactory::make("fakesink") + .property("sync", true) .build()?; - bin.add_many([&pulsesrc, &audioresample, &capsfilter])?; - gst::Element::link_many([&pulsesrc, &audioresample, &capsfilter])?; + let elements = vec![pulsesrc, audioconvert, level, tee, fakesink]; + imp.inner.add_many(&elements)?; + gst::Element::link_many(&elements)?; - let audiomixer_sink_pad = audiomixer - .request_pad_simple("sink_%u") - .context("Failed to request sink_%u pad from audiomixer")?; - capsfilter - .static_pad("src") - .unwrap() - .link(&audiomixer_sink_pad)?; - } + for element in &elements { + element.sync_state_with_parent()?; + } - let queue_pad = queue.static_pad("src").unwrap(); - bin.add_pad( - &gst::GhostPad::builder_with_target(&queue_pad)? - .name("src") - .build(), - )?; + imp.microphone_elements.replace(elements); - Ok(bin) -} + tracing::debug!("Loaded microphone from {}", device_name); -fn round_to_even(number: i32) -> i32 { - number / 2 * 2 -} + Ok(()) + } -fn round_to_even_f32(number: f32) -> i32 { - (number / 2.0).round() as i32 * 2 -} + pub fn unload_microphone(&self) -> Result<()> { + let imp = self.imp(); -fn new_recording_path(saving_location: &Path, extension: impl AsRef) -> PathBuf { - let file_name = glib::DateTime::now_local() - .expect("You are somehow on year 9999") - .format("Kooha-%F-%H-%M-%S") - .expect("Invalid format string"); + for element in imp.microphone_elements.take() { + element.set_state(gst::State::Null)?; + imp.inner.remove(&element)?; + } - let mut path = saving_location.join(file_name); - path.set_extension(extension); + tracing::debug!("Unloaded microphone"); - path -} + Ok(()) + } -#[cfg(test)] -mod test { - use super::*; + fn set_recording_state(&self, recording_state: RecordingState) { + let imp = self.imp(); - macro_rules! assert_even { - ($number:expr) => { - assert_eq!($number % 2, 0) - }; + if recording_state == imp.recording_state.get() { + return; + } + + imp.recording_state.set(recording_state); + self.notify_recording_state(); } - #[test] - fn odd_round_to_even() { - assert_even!(round_to_even(5)); - assert_even!(round_to_even(101)); + fn handle_bus_message(&self, message: &gst::Message) -> glib::ControlFlow { + let imp = self.imp(); + + match message.view() { + gst::MessageView::Element(e) => { + tracing::trace!(?message, "Element message from bus"); + + if let Some(src) = e.src() { + if let Some(structure) = e.structure() { + if structure.has_name("level") { + let peaks = structure.get::<&glib::ValueArray>("rms").unwrap(); + let left_peak = peaks.nth(0).unwrap().get::().unwrap(); + let right_peak = peaks.nth(1).unwrap().get::().unwrap(); + + let normalized_left_peak = 10_f64.powf(left_peak / 20.0); + let normalized_right_peak = 10_f64.powf(right_peak / 20.0); + + match src.name().as_str() { + DESKTOP_AUDIO_LEVEL_NAME => { + self.emit_by_name::<()>( + "desktop-audio-peak", + &[&Peaks::new(normalized_left_peak, normalized_right_peak)], + ); + } + MICROPHONE_LEVEL_NAME => { + self.emit_by_name::<()>( + "microphone-peak", + &[&Peaks::new(normalized_left_peak, normalized_right_peak)], + ); + } + _ => unreachable!(), + } + } + } + } + + glib::ControlFlow::Continue + } + gst::MessageView::StateChanged(sc) => { + if message + .src() + .is_some_and(|src| src == imp.inner.upcast_ref::()) + { + tracing::debug!( + "Pipeline changed state from `{:?}` -> `{:?}`", + sc.old(), + sc.current(), + ); + } + + glib::ControlFlow::Continue + } + gst::MessageView::Error(e) => { + tracing::error!(src = ?e.src(), error = ?e.error(), debug = ?e.debug(), "Error from bus"); + + glib::ControlFlow::Break + } + gst::MessageView::Warning(w) => { + tracing::warn!("Warning from bus: {:?}", w); + + glib::ControlFlow::Continue + } + gst::MessageView::Info(i) => { + tracing::debug!("Info from bus: {:?}", i); + + glib::ControlFlow::Continue + } + _ => { + tracing::trace!(?message, "Message from bus"); + + glib::ControlFlow::Continue + } + } } - #[test] - fn odd_round_to_even_f32() { - assert_even!(round_to_even_f32(3.0)); - assert_even!(round_to_even_f32(99.0)); + fn update_stream_size(&self) { + let imp = self.imp(); + + let compositor = imp.inner.by_name(COMPOSITOR_NAME).unwrap(); + let stream_size = compositor.static_pad("src").unwrap().caps().map(|caps| { + let caps_struct = caps.structure(0).unwrap(); + let stream_width = caps_struct.get::("width").unwrap(); + let stream_height = caps_struct.get::("height").unwrap(); + StreamSize::new(stream_width, stream_height) + }); + + imp.stream_size.set(stream_size); + self.notify_stream_size(); } - #[test] - fn even_round_to_even() { - assert_even!(round_to_even(50)); - assert_even!(round_to_even(4)); + fn setup_elements(&self) -> Result<()> { + let imp = self.imp(); + + let compositor = gst::ElementFactory::make("compositor") + .name(COMPOSITOR_NAME) + .build()?; + let tee = gst::ElementFactory::make("tee") + .name(VIDEO_TEE_NAME) + .build()?; + let convert = gst::ElementFactory::make("videoconvert").build()?; + let sink = gst::ElementFactory::make("gtk4paintablesink") + .name(PAINTABLE_SINK_NAME) + .build()?; + + imp.inner.add_many([&compositor, &tee, &convert, &sink])?; + gst::Element::link_many([&compositor, &tee, &convert, &sink])?; + + let bus_watch_guard = imp.inner.bus().unwrap().add_watch_local( + clone!(@weak self as obj => @default-panic, move |_, message| { + obj.handle_bus_message(message) + }), + )?; + imp.bus_watch_guard.replace(Some(bus_watch_guard)); + + let (tx, rx) = glib::MainContext::channel(glib::Priority::DEFAULT); + compositor + .static_pad("src") + .unwrap() + .connect_caps_notify(move |_| { + tx.send(()).unwrap(); + }); + let source_id = rx.attach( + None, + clone!(@weak self as obj => @default-panic, move |_| { + obj.update_stream_size(); + glib::ControlFlow::Continue + }), + ); + imp.caps_notify_source_id.replace(Some(source_id)); + + Ok(()) } +} - #[test] - fn even_round_to_even_f32() { - assert_even!(round_to_even_f32(300.0)); - assert_even!(round_to_even_f32(6.0)); +impl Default for Pipeline { + fn default() -> Self { + Self::new() } +} - #[test] - fn float_round_to_even_f32() { - assert_even!(round_to_even_f32(5.3)); - assert_even!(round_to_even_f32(2.9)); +/// Create a videocrop element that computes the crop from the given coordinates +/// and size. +fn videocrop_compute(data: &CropData, stream_size: StreamSize) -> Result { + let scale_h = stream_size.width() as f32 / data.full_rect.width(); + let scale_v = stream_size.height() as f32 / data.full_rect.height(); + + if scale_h != scale_v { + tracing::warn!( + scale_h, + scale_v, + "Scale factors of horizontal and vertical are unequal" + ); } + + // Both selection and full rect position are relative to the widget coordinates. + // To get the absolute position and so correct crop values, subtract the full + // rect's position from the selection rect. + let x = (data.selection_rect.x() - data.full_rect.x()) * scale_h; + let y = (data.selection_rect.y() - data.full_rect.y()) * scale_v; + let width = data.selection_rect.width() * scale_h; + let height = data.selection_rect.height() * scale_v; + + tracing::trace!(x, y, width, height); + + // x264enc requires even resolution. + let top_crop = round_to_even_f32(y); + let left_crop = round_to_even_f32(x); + let right_crop = round_to_even_f32(stream_size.width() as f32 - (x + width)); + let bottom_crop = round_to_even_f32(stream_size.height() as f32 - (y + height)); + + tracing::trace!(top_crop, left_crop, right_crop, bottom_crop); + + let crop = gst::ElementFactory::make("videocrop") + .property("top", top_crop.clamp(0, stream_size.height())) + .property("left", left_crop.clamp(0, stream_size.width())) + .property("right", right_crop.clamp(0, stream_size.width())) + .property("bottom", bottom_crop.clamp(0, stream_size.height())) + .build()?; + Ok(crop) +} + +fn round_to_even(number: i32) -> i32 { + number / 2 * 2 +} + +fn round_to_even_f32(number: f32) -> i32 { + (number / 2.0).round() as i32 * 2 } diff --git a/src/preferences_window.rs b/src/preferences_window.rs index 58ecee6f1..b9d2ea201 100644 --- a/src/preferences_window.rs +++ b/src/preferences_window.rs @@ -52,15 +52,12 @@ mod imp { |obj, _, _| async move { if let Err(err) = obj.settings().select_saving_location(&obj).await { tracing::error!("Failed to select saving location: {:?}", err); - let dialog = adw::MessageDialog::builder() - .heading(gettext("Failed to select saving location")) - .body(err.to_string()) - .default_response("ok") - .modal(true) - .build(); - dialog.add_response("ok", &gettext("Ok")); - dialog.set_transient_for(Some(&obj)); - dialog.present(); + if !err + .downcast_ref::() + .is_some_and(|error| error.matches(gtk::DialogError::Dismissed)) + { + obj.add_message_toast(&gettext("Failed to set saving location")); + } } }, ); @@ -168,6 +165,11 @@ impl PreferencesWindow { .build() } + fn add_message_toast(&self, message: &str) { + let toast = adw::Toast::new(message); + self.add_toast(toast); + } + fn update_file_chooser_button(&self) { let saving_location_display = self.settings().saving_location().display().to_string(); diff --git a/src/profile.rs b/src/profile.rs index 6625cbbe6..4f7dc2cba 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -66,7 +66,7 @@ impl BoxedProfile { } fn new_inner(profile: Option>) -> Self { - let this: Self = glib::Object::builder().build(); + let this: Self = glib::Object::new(); this.imp().0.set(profile).unwrap(); this } diff --git a/src/recording.rs b/src/recording.rs deleted file mode 100644 index 5bbd30d99..000000000 --- a/src/recording.rs +++ /dev/null @@ -1,581 +0,0 @@ -use anyhow::{ensure, Context, Error, Result}; -use gettextrs::gettext; -use gst::prelude::*; -use gtk::{ - gio::{self, prelude::*}, - glib::{self, clone, closure_local, subclass::prelude::*}, -}; - -use std::{ - cell::{Cell, OnceCell, RefCell}, - error, fmt, - os::unix::prelude::RawFd, - rc::Rc, - time::Duration, -}; - -use crate::{ - area_selector::AreaSelector, - audio_device::{self, Class as AudioDeviceClass}, - cancelled::Cancelled, - help::{ErrorExt, ResultExt}, - i18n::gettext_f, - pipeline::PipelineBuilder, - screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType, Stream}, - settings::{CaptureMode, Settings}, - timer::Timer, - utils, -}; - -const DEFAULT_DURATION_UPDATE_INTERVAL: Duration = Duration::from_millis(200); - -#[derive(Debug)] -pub struct NoProfileError; - -impl fmt::Display for NoProfileError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&gettext("No active profile")) - } -} - -impl error::Error for NoProfileError {} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Boxed)] -#[boxed_type(name = "KoohaRecordingState")] -pub enum State { - #[default] - Init, - Delayed { - secs_left: u64, - }, - Recording, - Paused, - Flushing, - Finished, -} - -#[derive(Debug, Clone, glib::SharedBoxed)] -#[shared_boxed_type(name = "KoohaRecordingResult")] -struct BoxedResult(Rc>); - -mod imp { - use super::*; - use glib::{once_cell::sync::Lazy, subclass::Signal}; - use gst::bus::BusWatchGuard; - - #[derive(Debug, Default, glib::Properties)] - #[properties(wrapper_type = super::Recording)] - pub struct Recording { - #[property(get)] - pub(super) state: Cell, - #[property(get)] - pub(super) duration: Cell, - - pub(super) file: OnceCell, - - pub(super) timer: RefCell>, - pub(super) session: RefCell>, - pub(super) duration_source_id: RefCell>, - pub(super) pipeline: OnceCell, - pub(super) bus_watch_guard: RefCell>, - } - - #[glib::object_subclass] - impl ObjectSubclass for Recording { - const NAME: &'static str = "KoohaRecording"; - type Type = super::Recording; - } - - #[glib::derived_properties] - impl ObjectImpl for Recording { - fn dispose(&self) { - if let Some(timer) = self.timer.take() { - timer.cancel(); - } - - if let Some(pipeline) = self.pipeline.get() { - if let Err(err) = pipeline.set_state(gst::State::Null) { - tracing::warn!("Failed to stop pipeline on dispose: {:?}", err); - } - } - - self.obj().close_session(); - - if let Some(source_id) = self.duration_source_id.take() { - source_id.remove(); - } - } - - fn signals() -> &'static [glib::subclass::Signal] { - static SIGNALS: Lazy> = Lazy::new(|| { - vec![Signal::builder("finished") - .param_types([BoxedResult::static_type()]) - .build()] - }); - - SIGNALS.as_ref() - } - } -} - -glib::wrapper! { - pub struct Recording(ObjectSubclass); -} - -impl Recording { - pub fn new() -> Self { - glib::Object::builder().build() - } - - pub async fn start(&self, parent: Option<&impl IsA>, settings: &Settings) { - if !matches!(self.state(), State::Init) { - tracing::error!("Trying to start recording on a non-init state"); - return; - } - - if let Err(err) = self.start_inner(parent, settings).await { - self.close_session(); - self.set_finished(Err(err)); - } - } - - async fn start_inner( - &self, - parent: Option<&impl IsA>, - settings: &Settings, - ) -> Result<()> { - let imp = self.imp(); - let profile = settings.profile().context(NoProfileError)?; - let profile_supports_audio = profile.supports_audio(); - - // setup screencast session - let restore_token = settings.screencast_restore_token(); - settings.set_screencast_restore_token(""); - let (screencast_session, streams, restore_token, fd) = new_screencast_session( - if settings.show_pointer() { - CursorMode::EMBEDDED - } else { - CursorMode::HIDDEN - }, - if utils::is_experimental_mode() { - SourceType::MONITOR | SourceType::WINDOW - } else { - SourceType::MONITOR - }, - true, - Some(&restore_token), - PersistMode::ExplicitlyRevoked, - parent, - ) - .await - .with_help( - || { - gettext_f( - // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. - "Check out {link} for help.", - &[("link", r#"It Doesn't Work page"#)], - ) - }, - || gettext("Failed to start recording"), - )?; - imp.session.replace(Some(screencast_session)); - settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); - - let mut pipeline_builder = PipelineBuilder::new( - &settings.saving_location(), - settings.video_framerate(), - profile, - fd, - streams.clone(), - ); - - // select area - if settings.capture_mode() == CaptureMode::Selection { - let data = - AreaSelector::present(Some(&utils::app_instance().window()), fd, &streams).await?; - pipeline_builder.select_area_data(data); - } - - // setup timer - let timer = Timer::new( - settings.record_delay(), - clone!(@weak self as obj => move |secs_left| { - obj.set_state(State::Delayed { - secs_left - }); - }), - ); - imp.timer.replace(Some(Timer::clone(&timer))); - timer.await?; - - // setup audio sources - if profile_supports_audio { - if settings.record_mic() { - pipeline_builder.mic_source( - audio_device::find_default_name(AudioDeviceClass::Source) - .await - .with_context(|| gettext("No microphone source found"))?, - ); - } - if settings.record_speaker() { - pipeline_builder.speaker_source( - audio_device::find_default_name(AudioDeviceClass::Sink) - .await - .with_context(|| gettext("No desktop speaker source found"))?, - ); - } - } - - // build pipeline - let pipeline = pipeline_builder.build().with_help( - || gettext("A GStreamer plugin may not be installed."), - || gettext("Failed to start recording"), - )?; - imp.pipeline.set(pipeline.clone()).unwrap(); - let location = pipeline - .by_name("filesink") - .context("Element filesink not found on pipeline")? - .property::("location"); - imp.file.set(gio::File::for_path(location)).unwrap(); - let bus_watch_guard = pipeline - .bus() - .unwrap() - .add_watch_local( - clone!(@weak self as obj => @default-return glib::ControlFlow::Break, move |_, message| { - obj.handle_bus_message(message) - }), - ) - .unwrap(); - imp.bus_watch_guard.replace(Some(bus_watch_guard)); - imp.duration_source_id.replace(Some(glib::timeout_add_local( - DEFAULT_DURATION_UPDATE_INTERVAL, - clone!(@weak self as obj => @default-return glib::ControlFlow::Break, move || { - obj.update_duration(); - glib::ControlFlow::Continue - }), - ))); - pipeline - .set_state(gst::State::Playing) - .context("Failed to initialize pipeline state to playing") - .with_help( - || gettext("Make sure that the saving location exists and is accessible."), - || gettext("Failed to start recording"), - )?; - self.update_duration(); - - Ok(()) - } - - pub fn pause(&self) -> Result<()> { - ensure!( - matches!(self.state(), State::Recording), - "Recording can only be paused from recording state" - ); - - self.pipeline() - .set_state(gst::State::Paused) - .context("Failed to set pipeline state to paused")?; - - Ok(()) - } - - pub fn resume(&self) -> Result<()> { - ensure!( - matches!(self.state(), State::Paused), - "Recording can only be resumed from paused state" - ); - - self.pipeline() - .set_state(gst::State::Playing) - .context("Failed to set pipeline state to playing from paused")?; - - Ok(()) - } - - pub fn stop(&self) { - let state = self.state(); - - if matches!(state, State::Init | State::Flushing | State::Finished) { - tracing::error!("Trying to stop recording on a `{:?}` state", state); - return; - } - - self.set_state(State::Flushing); - - tracing::debug!("Sending eos event to pipeline"); - // FIXME Maybe it is needed to verify if we received the same - // eos event by checking its seqnum in the bus? - self.pipeline().send_event(gst::event::Eos::new()); - } - - pub fn cancel(&self) { - let imp = self.imp(); - - tracing::debug!("Cancelling recording"); - - if let Some(timer) = imp.timer.take() { - timer.cancel(); - } - - if let Some(pipeline) = imp.pipeline.get() { - if let Err(err) = pipeline.set_state(gst::State::Null) { - tracing::warn!("Failed to stop pipeline on cancel: {:?}", err); - } - } - - let _ = imp.bus_watch_guard.take(); - - self.close_session(); - - if let Some(source_id) = imp.duration_source_id.take() { - source_id.remove(); - } - - // HACK we need to return before calling this to avoid a `BorrowMutError` when - // `Window` tried to take the `recording` on finished callback while `recording` - // is borrowed to call `cancel`. - glib::idle_add_local_once(clone!(@weak self as obj => move || { - obj.set_finished(Err(Error::from(Cancelled::new("recording")))); - })); - - self.file().delete_async( - glib::Priority::DEFAULT_IDLE, - gio::Cancellable::NONE, - |res| { - if let Err(err) = res { - tracing::warn!("Failed to delete recording file: {:?}", err); - } - }, - ); - } - - pub fn connect_finished(&self, f: F) -> glib::SignalHandlerId - where - F: Fn(&Self, &Result) + 'static, - { - self.connect_closure( - "finished", - true, - closure_local!(|obj: &Self, result: BoxedResult| { - f(obj, &result.0); - }), - ) - } - - fn set_state(&self, state: State) { - if state == self.state() { - return; - } - - self.imp().state.replace(state); - self.notify_state(); - } - - fn file(&self) -> &gio::File { - self.imp() - .file - .get() - .expect("file not set, make sure to start recording first") - } - - fn pipeline(&self) -> &gst::Pipeline { - self.imp() - .pipeline - .get() - .expect("pipeline not set, make sure to start recording first") - } - - fn set_finished(&self, res: Result) { - self.set_state(State::Finished); - - let result = BoxedResult(Rc::new(res)); - self.emit_by_name::<()>("finished", &[&result]); - } - - /// Closes session on the background - fn close_session(&self) { - if let Some(session) = self.imp().session.take() { - glib::spawn_future_local(async move { - if let Err(err) = session.close().await { - tracing::warn!("Failed to close screencast session: {:?}", err); - } - }); - } - } - - fn update_duration(&self) { - let clock_time = self - .imp() - .pipeline - .get() - .and_then(|pipeline| pipeline.query_position::()) - .unwrap_or(gst::ClockTime::ZERO); - - if clock_time == self.duration() { - return; - } - - self.imp().duration.set(clock_time); - self.notify_duration(); - } - - fn handle_bus_message(&self, message: &gst::Message) -> glib::ControlFlow { - use gst::MessageView; - - let imp = self.imp(); - - match message.view() { - MessageView::Error(e) => { - tracing::debug!(state = ?self.state(), "Received error at bus"); - - if let Err(err) = self.pipeline().set_state(gst::State::Null) { - tracing::warn!("Failed to stop pipeline on error: {:?}", err); - } - - self.close_session(); - - if let Some(source_id) = imp.duration_source_id.take() { - source_id.remove(); - } - - // TODO print error quarks for all glib::Error - - let error = Error::from(e.error()) - .context(e.debug().unwrap_or_else(|| "".into())) - .context(gettext("An error occurred while recording")); - - let error = if e.error().matches(gst::ResourceError::OpenWrite) { - error.help( - gettext("Make sure that the saving location exists and is accessible."), - gettext_f( - // Translators: Do NOT translate the contents between '{' and '}', this is a variable name. - "Failed to open “{path}” for writing", - &[("path", &self.file().uri())], - ), - ) - } else { - error - }; - - self.set_finished(Err(error)); - - glib::ControlFlow::Break - } - MessageView::Eos(..) => { - tracing::debug!("Eos signal received from record bus"); - - if self.state() != State::Flushing { - tracing::error!("Received an Eos signal on a {:?} state", self.state()); - } - - if let Err(err) = self.pipeline().set_state(gst::State::Null) { - tracing::error!("Failed to stop pipeline on eos: {:?}", err); - } - - self.close_session(); - - if let Some(source_id) = imp.duration_source_id.take() { - source_id.remove(); - } - - self.set_finished(Ok(self.file().clone())); - - glib::ControlFlow::Break - } - MessageView::StateChanged(sc) => { - let new_state = sc.current(); - - if message.src() - != imp - .pipeline - .get() - .map(|pipeline| pipeline.upcast_ref::()) - { - tracing::trace!( - "`{}` changed state from `{:?}` -> `{:?}`", - message - .src() - .map_or_else(|| "".into(), |e| e.name()), - sc.old(), - new_state, - ); - return glib::ControlFlow::Continue; - } - - tracing::debug!( - "Pipeline changed state from `{:?}` -> `{:?}`", - sc.old(), - new_state, - ); - - let state = match new_state { - gst::State::Paused => State::Paused, - gst::State::Playing => State::Recording, - _ => return glib::ControlFlow::Continue, - }; - self.set_state(state); - - glib::ControlFlow::Continue - } - MessageView::Warning(w) => { - tracing::warn!("Received warning message on bus: {:?}", w); - glib::ControlFlow::Continue - } - MessageView::Info(i) => { - tracing::debug!("Received info message on bus: {:?}", i); - glib::ControlFlow::Continue - } - other => { - tracing::trace!("Received other message on bus: {:?}", other); - glib::ControlFlow::Continue - } - } - } -} - -impl Default for Recording { - fn default() -> Self { - Self::new() - } -} - -async fn new_screencast_session( - cursor_mode: CursorMode, - source_type: SourceType, - is_multiple_sources: bool, - restore_token: Option<&str>, - persist_mode: PersistMode, - parent_window: Option<&impl IsA>, -) -> Result<(ScreencastSession, Vec, Option, RawFd)> { - let screencast_session = ScreencastSession::new() - .await - .context("Failed to create ScreencastSession")?; - - tracing::debug!( - "ScreenCast portal version: {:?}", - screencast_session.version() - ); - tracing::debug!( - "Available cursor modes: {:?}", - screencast_session.available_cursor_modes() - ); - tracing::debug!( - "Available source types: {:?}", - screencast_session.available_source_types() - ); - - // TODO handle Closed signal from service side - let (streams, restore_token, fd) = screencast_session - .begin( - cursor_mode, - source_type, - is_multiple_sources, - restore_token, - persist_mode, - parent_window, - ) - .await - .context("Failed to begin ScreencastSession")?; - - Ok((screencast_session, streams, restore_token, fd)) -} diff --git a/src/settings.rs b/src/settings.rs index 8582a8ab0..aa4676c22 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -38,17 +38,9 @@ impl Settings { .initial_folder(&gio::File::for_path(self.saving_location())) .build(); - match dialog.select_folder_future(Some(transient_for)).await { - Ok(folder) => { - let path = folder.path().context("Folder does not have a path")?; - self.0.set("saving-location", path).unwrap(); - } - Err(err) => { - if !err.matches(gtk::DialogError::Dismissed) { - return Err(err.into()); - } - } - } + let folder = dialog.select_folder_future(Some(transient_for)).await?; + let path = folder.path().context("Folder does not have a path")?; + self.0.set("saving-location", path).unwrap(); Ok(()) } diff --git a/src/timer.rs b/src/timer.rs index 2afbe8ff6..5c79d45aa 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -15,7 +15,9 @@ use crate::cancelled::Cancelled; const DEFAULT_SECS_LEFT_UPDATE_INTERVAL: Duration = Duration::from_millis(200); -/// Reference counted cancellable timer future +/// A reference counted cancellable timed future +/// +/// The timer will only start when it gets polled. #[derive(Clone)] pub struct Timer { inner: Rc, @@ -127,7 +129,7 @@ impl Future for Timer { Poll::Pending => {} } - if self.inner.duration == Duration::ZERO { + if self.inner.duration.is_zero() { self.inner.state.set(State::Done); return Poll::Ready(Ok(())); } diff --git a/src/toggle_button.rs b/src/toggle_button.rs index 1a7a4ef6c..b4ffbf895 100644 --- a/src/toggle_button.rs +++ b/src/toggle_button.rs @@ -111,7 +111,7 @@ glib::wrapper! { impl ToggleButton { pub fn new() -> Self { - glib::Object::builder().build() + glib::Object::new() } fn update_icon_name(&self) { diff --git a/src/utils.rs b/src/utils.rs index ce6ac63be..bcfd88699 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,10 +4,11 @@ use gtk::{ glib::{self, prelude::*}, }; -use std::{env, path::Path}; +use std::env; use crate::Application; +const MIN_THREAD_COUNT: u32 = 1; const MAX_THREAD_COUNT: u32 = 64; /// Get the global instance of `Application`. @@ -24,14 +25,9 @@ pub fn app_instance() -> Application { gio::Application::default().unwrap().downcast().unwrap() } -/// Whether the application is running in a flatpak sandbox. -pub fn is_flatpak() -> bool { - Path::new("/.flatpak-info").exists() -} - /// Ideal thread count to use for `GStreamer` processing. pub fn ideal_thread_count() -> u32 { - glib::num_processors().min(MAX_THREAD_COUNT) + glib::num_processors().clamp(MIN_THREAD_COUNT, MAX_THREAD_COUNT) } pub fn is_experimental_mode() -> bool { diff --git a/src/area_selector/view_port.rs b/src/view_port.rs similarity index 74% rename from src/area_selector/view_port.rs rename to src/view_port.rs index d1c3c7ee0..0c812d342 100644 --- a/src/area_selector/view_port.rs +++ b/src/view_port.rs @@ -5,7 +5,7 @@ use gtk::{ gdk, glib::{self, clone}, graphene::{Point, Rect}, - gsk::RoundedRect, + gsk::{self, RoundedRect}, prelude::*, subclass::prelude::*, }; @@ -15,11 +15,21 @@ use std::{ fmt, }; +// TODO +// * Handle selection outside paintable rect, when setting selection. +// * Add animation when entering/leaving selection mode. +// * Add undo and redo. +// * Add minimum selection size. + const DEFAULT_SIZE: f64 = 100.0; -const SELECTION_COLOR: gdk::RGBA = gdk::RGBA::WHITE; -const SELECTION_HANDLE_RADIUS: f32 = 12.0; +const SHADE_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.5); + const SELECTION_LINE_WIDTH: f32 = 2.0; +const SELECTION_LINE_COLOR: gdk::RGBA = gdk::RGBA::WHITE.with_alpha(0.6); +const SELECTION_HANDLE_COLOR: gdk::RGBA = gdk::RGBA::WHITE; +const SELECTION_HANDLE_SHADOW_COLOR: gdk::RGBA = gdk::RGBA::new(0.0, 0.0, 0.0, 0.2); +const SELECTION_HANDLE_RADIUS: f32 = 12.0; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] enum CursorType { @@ -55,7 +65,7 @@ impl CursorType { } } -#[derive(Default, Clone, Copy, glib::Boxed)] +#[derive(Default, Clone, Copy, PartialEq, glib::Boxed)] #[boxed_type(name = "KoohaSelection", nullable)] pub struct Selection { start_x: f32, @@ -77,6 +87,24 @@ impl fmt::Debug for Selection { } impl Selection { + pub fn new(start_x: f32, start_y: f32, end_x: f32, end_y: f32) -> Self { + Self { + start_x, + start_y, + end_x, + end_y, + } + } + + pub fn from_rect(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { + start_x: x, + start_y: y, + end_x: x + width, + end_y: y + height, + } + } + pub fn left_x(&self) -> f32 { self.start_x.min(self.end_x) } @@ -106,12 +134,13 @@ impl Selection { mod imp { use super::*; - #[derive(Debug, Default, glib::Properties)] + #[derive(Debug, Default, glib::Properties, gtk::CompositeTemplate)] + #[template(resource = "/io/github/seadve/Kooha/ui/view-port.ui")] #[properties(wrapper_type = super::ViewPort)] pub struct ViewPort { #[property(get, set = Self::set_paintable, explicit_notify, nullable)] pub(super) paintable: RefCell>, - #[property(get)] + #[property(get, set = Self::set_selection, explicit_notify, nullable)] pub(super) selection: Cell>, pub(super) paintable_rect: Cell>, @@ -130,41 +159,20 @@ mod imp { const NAME: &'static str = "KoohaViewPort"; type Type = super::ViewPort; type ParentType = gtk::Widget; - } - - #[glib::derived_properties] - impl ObjectImpl for ViewPort { - fn constructed(&self) { - self.parent_constructed(); - - let obj = self.obj(); - let motion_controller = gtk::EventControllerMotion::new(); - motion_controller.connect_enter(clone!(@weak obj => move |controller, x, y| { - obj.on_enter(controller, x, y); - })); - motion_controller.connect_motion(clone!(@weak obj => move |controller, x, y| { - obj.on_motion(controller, x, y); - })); - motion_controller.connect_leave(clone!(@weak obj => move |controller| { - obj.on_leave(controller); - })); - obj.add_controller(motion_controller); + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } - let gesture_drag = gtk::GestureDrag::builder().exclusive(true).build(); - gesture_drag.connect_drag_begin(clone!(@weak obj => move |controller, x, y| { - obj.on_drag_begin(controller, x, y); - })); - gesture_drag.connect_drag_update(clone!(@weak obj => move |controller, dx, dy| { - obj.on_drag_update(controller, dx, dy); - })); - gesture_drag.connect_drag_end(clone!(@weak obj => move |controller, dx, dy| { - obj.on_drag_end(controller, dx, dy); - })); - obj.add_controller(gesture_drag); + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); } } + #[glib::derived_properties] + impl ObjectImpl for ViewPort {} + impl WidgetImpl for ViewPort { fn request_mode(&self) -> gtk::SizeRequestMode { gtk::SizeRequestMode::HeightForWidth @@ -172,7 +180,7 @@ mod imp { fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { if for_size == 0 { - return (0, 0, 0, 0); + return (0, 0, -1, -1); } let Some(paintable) = self.obj().paintable() else { @@ -180,7 +188,7 @@ mod imp { }; if orientation == gtk::Orientation::Horizontal { - let (natural_width, _natural_height) = paintable.compute_concrete_size( + let (natural_width, _) = paintable.compute_concrete_size( 0.0, if for_size < 0 { 0.0 } else { for_size as f64 }, DEFAULT_SIZE, @@ -188,7 +196,7 @@ mod imp { ); (0, natural_width.ceil() as i32, -1, -1) } else { - let (_natural_width, natural_height) = paintable.compute_concrete_size( + let (_, natural_height) = paintable.compute_concrete_size( if for_size < 0 { 0.0 } else { for_size as f64 }, 0.0, DEFAULT_SIZE, @@ -198,112 +206,115 @@ mod imp { } } - fn snapshot(&self, snapshot: >k::Snapshot) { + fn size_allocate(&self, width: i32, height: i32, _baseline: i32) { let obj = self.obj(); - if let Some(paintable) = obj.paintable() { - let widget_width = obj.width() as f64; - let widget_height = obj.height() as f64; - let widget_ratio = widget_width / widget_height; - - let paintable_width = paintable.intrinsic_width() as f64; - let paintable_height = paintable.intrinsic_height() as f64; - let paintable_ratio = paintable.intrinsic_aspect_ratio(); - - let (width, height) = - if widget_width >= paintable_width && widget_height >= paintable_height { - (paintable_width, paintable_height) - } else if paintable_ratio > widget_ratio { - (widget_width, widget_width / paintable_ratio) - } else { - (widget_height * paintable_ratio, widget_height) - }; - let x = (widget_width - width.ceil()) / 2.0; - let y = (widget_height - height.ceil()).floor() / 2.0; - - obj.imp().paintable_rect.set(Some(Rect::new( - x as f32, - y as f32, - width as f32, - height as f32, - ))); + let Some(paintable) = obj.paintable() else { + self.paintable_rect.set(None); + return; + }; + + let width = width as f64; + let height = height as f64; + let ratio = width / height; + + let paintable_width = paintable.intrinsic_width() as f64; + let paintable_height = paintable.intrinsic_height() as f64; + let paintable_ratio = paintable.intrinsic_aspect_ratio(); + let (rel_paintable_width, rel_paintable_height) = + if width >= paintable_width && height >= paintable_height { + (paintable_width, paintable_height) + } else if paintable_ratio > ratio { + (width, width / paintable_ratio) + } else { + (height * paintable_ratio, height) + }; + + let new_paintable_rect = Rect::new( + ((width - rel_paintable_width) / 2.0).floor() as f32, + ((height - rel_paintable_height) / 2.0).floor() as f32, + rel_paintable_width.ceil() as f32, + rel_paintable_height.ceil() as f32, + ); + let prev_paintable_rect = self.paintable_rect.replace(Some(new_paintable_rect)); + + // Update selection if paintable rect changed. + if let Some(prev_paintable_rect) = prev_paintable_rect { + if let Some(selection) = obj.selection() { + let selection_rect = selection.rect(); + + let scale_x = new_paintable_rect.width() / prev_paintable_rect.width(); + let scale_y = new_paintable_rect.height() / prev_paintable_rect.height(); + + let rel_x = selection_rect.x() - prev_paintable_rect.x(); + let rel_y = selection_rect.y() - prev_paintable_rect.y(); + + obj.set_selection(Some(Selection::from_rect( + new_paintable_rect.x() + rel_x * scale_x, + new_paintable_rect.y() + rel_y * scale_y, + selection_rect.width() * scale_x, + selection_rect.height() * scale_y, + ))); + } + } + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let obj = self.obj(); + + if let Some(paintable_rect) = obj.paintable_rect() { snapshot.save(); - snapshot.translate(&Point::new(x as f32, y as f32)); - paintable.snapshot(snapshot, width, height); + + snapshot.translate(&Point::new(paintable_rect.x(), paintable_rect.y())); + + let paintable = obj.paintable().unwrap(); + paintable.snapshot( + snapshot, + paintable_rect.width() as f64, + paintable_rect.height() as f64, + ); + snapshot.restore(); } if let Some(selection) = obj.selection() { - let selection_rect = selection.rect(); + let selection_rect = selection.rect().round_extents(); + // Shades the area outside the selection. if let Some(paintable_rect) = obj.paintable_rect() { - let shade_color = gdk::RGBA::new(0.0, 0.0, 0.0, 0.5); - snapshot.append_color( - &shade_color, - &Rect::new( - paintable_rect.x(), - paintable_rect.y(), - selection.left_x() - paintable_rect.x(), - paintable_rect.height(), - ), - ); - snapshot.append_color( - &shade_color, - &Rect::new( - selection.right_x(), - paintable_rect.y(), - paintable_rect.width() + paintable_rect.x() - selection.right_x(), - paintable_rect.height(), - ), - ); - snapshot.append_color( - &shade_color, - &Rect::new( - selection.left_x(), - paintable_rect.y(), - selection_rect.width(), - selection.top_y() - paintable_rect.y(), - ), - ); - snapshot.append_color( - &shade_color, - &Rect::new( - selection.left_x(), - selection.bottom_y(), - selection_rect.width(), - paintable_rect.height() + paintable_rect.y() - selection.bottom_y(), - ) - .normalize_r(), - ); + snapshot.push_mask(gsk::MaskMode::InvertedAlpha); + + snapshot.append_color(&gdk::RGBA::BLACK, &selection_rect.inset_r(3.0, 3.0)); + snapshot.pop(); + + snapshot.append_color(&SHADE_COLOR, &paintable_rect); + snapshot.pop(); } - snapshot.append_border( - &RoundedRect::from_rect( - Rect::new( - selection_rect.x(), - selection_rect.y(), - selection_rect.width().max(1.0), - selection_rect.height().max(1.0), - ), - 0.0, - ), - &[SELECTION_LINE_WIDTH; 4], - &[SELECTION_COLOR; 4], + let path_builder = gsk::PathBuilder::new(); + path_builder.add_rect(&selection_rect); + snapshot.append_stroke( + &path_builder.to_path(), + &gsk::Stroke::builder(SELECTION_LINE_WIDTH) + .dash(&[10.0, 6.0]) + .build(), + &SELECTION_LINE_COLOR, ); for handle in self.selection_handles.get().unwrap() { let bounds = RoundedRect::from_rect(handle, SELECTION_HANDLE_RADIUS); snapshot.append_outset_shadow( &bounds, - &gdk::RGBA::new(0.0, 0.0, 0.0, 0.2), + &SELECTION_HANDLE_SHADOW_COLOR, 0.0, 1.0, 2.0, 3.0, ); + snapshot.push_rounded_clip(&bounds); - snapshot.append_color(&SELECTION_COLOR, &handle); + snapshot.append_color(&SELECTION_HANDLE_COLOR, &handle); snapshot.pop(); } } @@ -318,8 +329,6 @@ mod imp { return; } - let _freeze_guard = obj.freeze_notify(); - let mut handler_ids = self.handler_ids.borrow_mut(); if let Some(previous_paintable) = self.paintable.replace(paintable.clone()) { @@ -341,9 +350,24 @@ mod imp { ); } + self.paintable_rect.set(None); + obj.queue_resize(); obj.notify_paintable(); } + + fn set_selection(&self, selection: Option) { + let obj = self.obj(); + + if selection == obj.selection() { + return; + } + + self.selection.set(selection); + obj.update_selection_handles(); + obj.queue_draw(); + obj.notify_selection(); + } } } @@ -354,32 +378,123 @@ glib::wrapper! { impl ViewPort { pub fn new() -> Self { - glib::Object::builder().build() + glib::Object::new() } pub fn paintable_rect(&self) -> Option { self.imp().paintable_rect.get() } - pub fn reset_selection(&self) { - self.set_selection(None); - self.update_selection_handles(); - self.queue_draw(); + fn set_cursor(&self, cursor_type: CursorType) { + self.set_cursor_from_name(Some(cursor_type.name())); } - fn set_selection(&self, selection: Option) { - self.imp().selection.set(selection); - self.notify_selection(); + fn compute_cursor_type(&self, x: f32, y: f32) -> CursorType { + let imp = self.imp(); + + let point = Point::new(x, y); + + if self.paintable().is_none() { + return CursorType::Default; + }; + + let Some(selection) = self.selection() else { + return CursorType::Crosshair; + }; + + let [top_left_handle, top_right_handle, bottom_right_handle, bottom_left_handle] = + imp.selection_handles.get().unwrap(); + + if top_left_handle.contains_point(&point) { + CursorType::NorthWestResize + } else if top_right_handle.contains_point(&point) { + CursorType::NorthEastResize + } else if bottom_right_handle.contains_point(&point) { + CursorType::SouthEastResize + } else if bottom_left_handle.contains_point(&point) { + CursorType::SouthWestResize + } else if selection.rect().contains_point(&point) { + CursorType::Move + } else if top_left_handle + .union(&top_right_handle) + .contains_point(&point) + { + CursorType::NorthResize + } else if top_right_handle + .union(&bottom_right_handle) + .contains_point(&point) + { + CursorType::EastResize + } else if bottom_right_handle + .union(&bottom_left_handle) + .contains_point(&point) + { + CursorType::SouthResize + } else if bottom_left_handle + .union(&top_left_handle) + .contains_point(&point) + { + CursorType::WestResize + } else { + CursorType::Crosshair + } + } + + fn update_selection_handles(&self) { + let imp = self.imp(); + + let Some(selection) = self.selection() else { + imp.selection_handles.set(None); + return; + }; + + let selection_handle_diameter = SELECTION_HANDLE_RADIUS * 2.0; + let top_left = Rect::new( + selection.left_x() - SELECTION_HANDLE_RADIUS, + selection.top_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let top_right = Rect::new( + selection.right_x() - SELECTION_HANDLE_RADIUS, + selection.top_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let bottom_right = Rect::new( + selection.right_x() - SELECTION_HANDLE_RADIUS, + selection.bottom_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + let bottom_left = Rect::new( + selection.left_x() - SELECTION_HANDLE_RADIUS, + selection.bottom_y() - SELECTION_HANDLE_RADIUS, + selection_handle_diameter, + selection_handle_diameter, + ); + + imp.selection_handles.set(Some([ + top_left.round_extents(), + top_right.round_extents(), + bottom_right.round_extents(), + bottom_left.round_extents(), + ])); } +} - fn on_enter(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { +#[gtk::template_callbacks] +impl ViewPort { + #[template_callback] + fn enter(&self, x: f64, y: f64) { let imp = self.imp(); imp.pointer_position .set(Some(Point::new(x as f32, y as f32))); } - fn on_motion(&self, _controller: >k::EventControllerMotion, x: f64, y: f64) { + #[template_callback] + fn motion(&self, x: f64, y: f64) { let imp = self.imp(); imp.pointer_position @@ -391,7 +506,8 @@ impl ViewPort { } } - fn on_leave(&self, _controller: >k::EventControllerMotion) { + #[template_callback] + fn leave(&self) { let imp = self.imp(); imp.pointer_position.set(None); @@ -399,8 +515,13 @@ impl ViewPort { self.set_cursor(CursorType::Default); } - fn on_drag_begin(&self, _gesture: >k::GestureDrag, x: f64, y: f64) { - tracing::debug!("Drag begin at ({}, {})", x, y); + #[template_callback] + fn drag_begin(&self, x: f64, y: f64) { + tracing::trace!("Drag begin at ({}, {})", x, y); + + let Some(paintable_rect) = self.paintable_rect() else { + return; + }; let imp = self.imp(); let cursor_type = self.compute_cursor_type(x as f32, y as f32); @@ -409,7 +530,6 @@ impl ViewPort { imp.drag_cursor.set(CursorType::Crosshair); self.set_cursor(CursorType::Crosshair); - let paintable_rect = self.paintable_rect().unwrap(); let x = (x as f32).clamp( paintable_rect.x(), paintable_rect.x() + paintable_rect.width(), @@ -424,13 +544,12 @@ impl ViewPort { end_x: x, end_y: y, })); - self.update_selection_handles(); } else { imp.drag_cursor.set(cursor_type); imp.drag_start.set(Some(Point::new(x as f32, y as f32))); let selection = self.selection().unwrap(); - let mut new_selection = self.selection().unwrap(); + let mut new_selection = selection; if cursor_type == CursorType::Move { new_selection.start_x = selection.left_x(); @@ -469,11 +588,14 @@ impl ViewPort { self.set_selection(Some(new_selection)); } - - self.queue_draw(); } - fn on_drag_update(&self, _gesture: >k::GestureDrag, _: f64, _: f64) { + #[template_callback] + fn drag_update(&self, _dx: f64, _dy: f64) { + let Some(paintable_rect) = self.paintable_rect() else { + return; + }; + let imp = self.imp(); let pointer_position = imp.pointer_position.get().unwrap(); @@ -484,7 +606,6 @@ impl ViewPort { let Selection { start_x, start_y, .. } = self.selection().unwrap(); - let paintable_rect = self.paintable_rect().unwrap(); self.set_selection(Some(Selection { start_x, start_y, @@ -517,10 +638,9 @@ impl ViewPort { let mut overshoot_x = 0.0; let mut overshoot_y = 0.0; - let paintable_rect = self.paintable_rect().unwrap(); let selection_rect = self.selection().unwrap().rect(); - // Keep the size intact if we bumped into the stage edge. + // Keep the size intact if we bumped to the paintable rect. if new_start_x < paintable_rect.x() { overshoot_x = paintable_rect.x() - new_start_x; new_start_x = paintable_rect.x(); @@ -560,7 +680,6 @@ impl ViewPort { dx = 0.0; } - let paintable_rect = self.paintable_rect().unwrap(); let mut new_selection = self.selection().unwrap(); new_selection.end_x += dx; @@ -630,13 +749,15 @@ impl ViewPort { imp.drag_start .set(Some(Point::new(drag_start.x() + dx, drag_start.y() + dy))); } - - self.update_selection_handles(); - self.queue_draw(); } - fn on_drag_end(&self, _gesture: >k::GestureDrag, dx: f64, dy: f64) { - tracing::debug!("Drag end offset ({}, {})", dx, dy); + #[template_callback] + fn drag_end(&self, dx: f64, dy: f64) { + tracing::trace!("Drag end offset ({}, {})", dx, dy); + + let Some(paintable_rect) = self.paintable_rect() else { + return; + }; let imp = self.imp(); imp.drag_start.set(None); @@ -654,10 +775,9 @@ impl ViewPort { selection.end_x += offset; selection.end_y += offset; - let paintable_rect = self.paintable_rect().unwrap(); let selection_rect = selection.rect(); - // Keep the coordinates inside the stage. + // Keep the coordinates inside the paintable rect. if selection.start_x < paintable_rect.x() { selection.start_x = paintable_rect.x(); selection.end_x = selection.start_x + selection_rect.width(); @@ -674,7 +794,6 @@ impl ViewPort { } self.set_selection(Some(selection)); - self.update_selection_handles(); } } @@ -683,95 +802,6 @@ impl ViewPort { self.set_cursor(cursor_type); } } - - fn set_cursor(&self, cursor_type: CursorType) { - self.set_cursor_from_name(Some(cursor_type.name())); - } - - fn compute_cursor_type(&self, x: f32, y: f32) -> CursorType { - let imp = self.imp(); - - let point = Point::new(x, y); - - let Some(selection) = self.selection() else { - return CursorType::Crosshair; - }; - - let [top_left_handle, top_right_handle, bottom_right_handle, bottom_left_handle] = - imp.selection_handles.get().unwrap(); - - if top_left_handle.contains_point(&point) { - CursorType::NorthWestResize - } else if top_right_handle.contains_point(&point) { - CursorType::NorthEastResize - } else if bottom_right_handle.contains_point(&point) { - CursorType::SouthEastResize - } else if bottom_left_handle.contains_point(&point) { - CursorType::SouthWestResize - } else if selection.rect().contains_point(&point) { - CursorType::Move - } else if top_left_handle - .union(&top_right_handle) - .contains_point(&point) - { - CursorType::NorthResize - } else if top_right_handle - .union(&bottom_right_handle) - .contains_point(&point) - { - CursorType::EastResize - } else if bottom_right_handle - .union(&bottom_left_handle) - .contains_point(&point) - { - CursorType::SouthResize - } else if bottom_left_handle - .union(&top_left_handle) - .contains_point(&point) - { - CursorType::WestResize - } else { - CursorType::Crosshair - } - } - - fn update_selection_handles(&self) { - let imp = self.imp(); - - let Some(selection) = self.selection() else { - imp.selection_handles.set(None); - return; - }; - - let selection_handle_diameter = SELECTION_HANDLE_RADIUS * 2.0; - let top_left = Rect::new( - selection.left_x() - SELECTION_HANDLE_RADIUS, - selection.top_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let top_right = Rect::new( - selection.right_x() - SELECTION_HANDLE_RADIUS, - selection.top_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let bottom_right = Rect::new( - selection.right_x() - SELECTION_HANDLE_RADIUS, - selection.bottom_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - let bottom_left = Rect::new( - selection.left_x() - SELECTION_HANDLE_RADIUS, - selection.bottom_y() - SELECTION_HANDLE_RADIUS, - selection_handle_diameter, - selection_handle_diameter, - ); - - imp.selection_handles - .set(Some([top_left, top_right, bottom_right, bottom_left])); - } } impl Default for ViewPort { diff --git a/src/window.rs b/src/window.rs index e4ae1502d..a33444d7a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,54 +1,58 @@ use adw::{prelude::*, subclass::prelude::*}; -use anyhow::{ensure, Error, Result}; +use anyhow::{Context, Result}; use gettextrs::gettext; use gtk::{ gio, glib::{self, clone}, - CompositeTemplate, }; -use std::cell::RefCell; - use crate::{ + application::Application, cancelled::Cancelled, config::PROFILE, - help::Help, - recording::{NoProfileError, Recording, State as RecordingState}, - settings::CaptureMode, + pipeline::{CropData, Pipeline, RecordingState}, + screencast_session::{CursorMode, PersistMode, ScreencastSession, SourceType}, + timer::Timer, toggle_button::ToggleButton, - utils, Application, + utils, + view_port::{Selection, ViewPort}, }; mod imp { + use std::cell::{Cell, RefCell}; + use super::*; - #[derive(Debug, Default, CompositeTemplate)] + #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/io/github/seadve/Kooha/ui/window.ui")] pub struct Window { #[template_child] - pub(super) title: TemplateChild, + pub(super) toast_overlay: TemplateChild, #[template_child] - pub(super) stack: TemplateChild, + pub(super) record_button: TemplateChild, #[template_child] - pub(super) main_page: TemplateChild, + pub(super) view_port: TemplateChild, #[template_child] - pub(super) forget_video_sources_revealer: TemplateChild, + pub(super) selection_toggle: TemplateChild, #[template_child] - pub(super) recording_page: TemplateChild, + pub(super) desktop_audio_level_left: TemplateChild, #[template_child] - pub(super) recording_label: TemplateChild, + pub(super) desktop_audio_level_right: TemplateChild, #[template_child] - pub(super) recording_time_label: TemplateChild, + pub(super) microphone_level_left: TemplateChild, #[template_child] - pub(super) pause_record_button: TemplateChild, + pub(super) microphone_level_right: TemplateChild, #[template_child] - pub(super) delay_page: TemplateChild, + pub(super) recording_indicator: TemplateChild, #[template_child] - pub(super) delay_label: TemplateChild, + pub(super) recording_time_label: TemplateChild, #[template_child] - pub(super) flushing_page: TemplateChild, + pub(super) info_label: TemplateChild, - pub(super) recording: RefCell)>>, + pub(super) pipeline: Pipeline, + pub(super) delay_timer: RefCell>, + pub(super) session: RefCell>, + pub(super) prev_selection: Cell>, } #[glib::object_subclass] @@ -58,29 +62,30 @@ mod imp { type ParentType = adw::ApplicationWindow; fn class_init(klass: &mut Self::Class) { - ToggleButton::static_type(); - klass.bind_template(); + ToggleButton::ensure_type(); - klass.install_action_async("win.toggle-record", None, |obj, _, _| async move { - obj.toggle_record().await; - }); + klass.bind_template(); - klass.install_action("win.toggle-pause", None, move |obj, _, _| { - if let Err(err) = obj.toggle_pause() { - let err = err.context(gettext("Failed to toggle pause")); - tracing::error!("{:?}", err); - obj.present_error(&err); + klass.install_action_async("win.select-video-source", None, |obj, _, _| async move { + if let Err(err) = obj.replace_session(None).await { + if err.is::() { + tracing::debug!("Select video source cancelled: {:?}", err); + } else { + obj.add_message_toast(&gettext("Failed to select video source")); + tracing::error!("Failed to select video source: {:?}", err); + } } }); - klass.install_action("win.cancel-record", None, move |obj, _, _| { - obj.cancel_record(); - }); - - klass.install_action("win.forget-video-sources", None, move |_obj, _, _| { - utils::app_instance() - .settings() - .set_screencast_restore_token(""); + klass.install_action_async("win.toggle-recording", None, |obj, _, _| async move { + if let Err(err) = obj.toggle_recording().await { + if err.is::() { + tracing::debug!("Recording cancelled: {:?}", err); + } else { + obj.add_message_toast(&gettext("Failed to toggle record")); + tracing::error!("Failed to toggle record: {:?}", err); + } + } }); } @@ -101,14 +106,98 @@ mod imp { obj.setup_settings(); - obj.update_view(); - obj.update_audio_toggles_sensitivity(); - obj.update_title_label(); + self.selection_toggle + .connect_active_notify(clone!(@weak obj => move |toggle| { + let imp = obj.imp(); + if toggle.is_active() { + let selection = imp.prev_selection.get().unwrap_or_else(|| { + let mid_x = imp.view_port.width() as f32 / 2.0; + let mid_y = imp.view_port.height() as f32 / 2.0; + let offset = 20.0 * imp.view_port.scale_factor() as f32; + Selection::new( + mid_x - offset, + mid_y - offset, + mid_x + offset, + mid_y + offset, + ) + }); + imp.view_port.set_selection(Some(selection)); + } else { + imp.view_port.set_selection(None::); + } + })); + self.view_port + .connect_selection_notify(clone!(@weak obj => move |view_port| { + if let Some(selection) = view_port.selection() { + obj.imp().prev_selection.replace(Some(selection)); + } + obj.update_selection_toggle(); + obj.update_info_label(); + })); + + self.pipeline + .connect_stream_size_notify(clone!(@weak obj => move |_| { + obj.update_selection_toggle_sensitivity(); + obj.update_info_label(); + })); + self.pipeline + .connect_recording_state_notify(clone!(@weak obj => move |_| { + obj.update_recording_ui(); + })); + self.pipeline + .connect_desktop_audio_peak(clone!(@weak obj => move |_, peaks| { + let imp = obj.imp(); + imp.desktop_audio_level_left.set_value(peaks.left()); + imp.desktop_audio_level_right.set_value(peaks.right()); + })); + self.pipeline + .connect_microphone_peak(clone!(@weak obj => move |_, peaks| { + let imp = obj.imp(); + imp.microphone_level_left.set_value(peaks.left()); + imp.microphone_level_right.set_value(peaks.right()); + })); + self.view_port + .set_paintable(Some(self.pipeline.paintable())); + + obj.load_window_size(); + + glib::spawn_future_local(clone!(@weak obj => async move { + if let Err(err) = obj.load_session().await { + tracing::error!("Failed to load session: {:?}", err); + } + })); + + obj.update_selection_toggle_sensitivity(); + obj.update_selection_toggle(); + obj.update_info_label(); + obj.update_recording_ui(); + obj.update_desktop_audio_pipeline(); + obj.update_microphone_pipeline(); + } + + fn dispose(&self) { + let obj = self.obj(); + + obj.close_session(); + + self.dispose_template(); } } impl WidgetImpl for Window {} - impl WindowImpl for Window {} + + impl WindowImpl for Window { + fn close_request(&self) -> glib::Propagation { + let obj = self.obj(); + + if let Err(err) = obj.save_window_size() { + tracing::warn!("Failed to save window state, {:?}", &err); + } + + self.parent_close_request() + } + } + impl ApplicationWindowImpl for Window {} impl AdwApplicationWindowImpl for Window {} } @@ -120,370 +209,359 @@ glib::wrapper! { } impl Window { - pub fn new(app: &Application) -> Self { - glib::Object::builder().property("application", app).build() + pub fn new(application: &Application) -> Self { + glib::Object::builder() + .property("application", application) + .build() } - pub fn close(&self) -> Result<()> { - let is_safe_to_close = - self.imp() - .recording - .borrow() - .as_ref() - .map_or(true, |(ref recording, _)| { - matches!( - recording.state(), - RecordingState::Init - | RecordingState::Delayed { .. } - | RecordingState::Finished - ) - }); + fn add_message_toast(&self, message: &str) { + let toast = adw::Toast::new(message); + self.imp().toast_overlay.add_toast(toast); + } - ensure!( - is_safe_to_close, - "Cannot close window while recording is in progress" - ); + fn start_recording(&self) -> Result<()> { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + let crop_data = imp.view_port.selection().map(|selection| CropData { + full_rect: imp.view_port.paintable_rect().unwrap(), + selection_rect: selection.rect(), + }); + imp.pipeline + .start_recording(&settings.saving_location(), crop_data)?; - GtkWindowExt::close(self); Ok(()) } - pub fn present_error(&self, err: &Error) { - let err_text = format!("{:?}", err); - - let err_view = gtk::TextView::builder() - .buffer(>k::TextBuffer::builder().text(&err_text).build()) - .editable(false) - .monospace(true) - .top_margin(6) - .bottom_margin(6) - .left_margin(6) - .right_margin(6) - .build(); - - let scrolled_window = gtk::ScrolledWindow::builder() - .child(&err_view) - .min_content_height(120) - .min_content_width(360) - .build(); - - let scrolled_window_row = gtk::ListBoxRow::builder() - .child(&scrolled_window) - .overflow(gtk::Overflow::Hidden) - .activatable(false) - .selectable(false) - .build(); - scrolled_window_row.add_css_class("error-view"); - - let copy_button = gtk::Button::builder() - .tooltip_text(gettext("Copy to clipboard")) - .icon_name("edit-copy-symbolic") - .valign(gtk::Align::Center) - .build(); - copy_button.connect_clicked(move |button| { - button.display().clipboard().set_text(&err_text); - button.set_tooltip_text(Some(&gettext("Copied to clipboard"))); - button.set_icon_name("checkmark-symbolic"); - button.add_css_class("copy-done"); - }); + fn stop_recording(&self) -> Result<()> { + let imp = self.imp(); - let expander = adw::ExpanderRow::builder() - .title(gettext("Show detailed error")) - .activatable(false) - .build(); - expander.add_row(&scrolled_window_row); - expander.add_suffix(©_button); - - let list_box = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .build(); - list_box.add_css_class("boxed-list"); - list_box.append(&expander); - - let err_dialog = adw::MessageDialog::builder() - .heading(err.to_string()) - .body_use_markup(true) - .default_response("ok") - .transient_for(self) - .modal(true) - .extra_child(&list_box) - .build(); - - if let Some(ref help) = err.downcast_ref::() { - err_dialog.set_body(&format!("{}: {}", gettext("Help"), help)); - } + imp.pipeline.stop_recording()?; - err_dialog.add_response("ok", &gettext("Ok")); - err_dialog.present(); + Ok(()) } - async fn toggle_record(&self) { + async fn toggle_recording(&self) -> Result<()> { let imp = self.imp(); - if let Some((ref recording, _)) = *imp.recording.borrow() { - recording.stop(); - return; - } + match imp.pipeline.recording_state() { + RecordingState::Idle => { + if let Some(delay_timer) = imp.delay_timer.take() { + delay_timer.cancel(); + self.update_recording_ui(); + return Ok(()); + } - let recording = Recording::new(); - let handler_ids = vec![ - recording.connect_state_notify(clone!(@weak self as obj => move |_| { - obj.update_view(); - })), - recording.connect_duration_notify(clone!(@weak self as obj => move |recording| { - let formatted_time = format_time(recording.duration()); - obj.imp().recording_time_label.set_label(&formatted_time); - })), - recording.connect_finished(clone!(@weak self as obj => move |recording, res| { - obj.on_recording_finished(recording, res); - })), - ]; - imp.recording - .replace(Some((recording.clone(), handler_ids))); + let app = utils::app_instance(); + let settings = app.settings(); - recording - .start(Some(self), utils::app_instance().settings()) - .await; - } + let delay_timer = Timer::new(settings.record_delay(), |secs_left| { + // TODO wire up to the UI + tracing::debug!("secs_left: {}", secs_left); + }); + imp.delay_timer.replace(Some(delay_timer.clone())); + self.update_recording_ui(); - fn toggle_pause(&self) -> Result<()> { - let imp = self.imp(); + delay_timer.await?; + + let _ = imp.delay_timer.take(); + self.update_recording_ui(); - if let Some((ref recording, _)) = *imp.recording.borrow() { - if matches!(recording.state(), RecordingState::Paused) { - recording.resume()?; - } else { - recording.pause()?; - }; + self.start_recording() + .context("Failed to start recording")?; + } + RecordingState::Started { .. } => { + self.stop_recording().context("Failed to stop recording")?; + } } Ok(()) } - fn cancel_record(&self) { + fn close_session(&self) { let imp = self.imp(); - if let Some((ref recording, _)) = *imp.recording.borrow() { - recording.cancel(); + if let Some(session) = imp.session.take() { + glib::spawn_future_local(async move { + if let Err(err) = session.close().await { + tracing::error!("Failed to end ScreencastSession: {:?}", err); + } + }); } } - fn on_recording_finished(&self, recording: &Recording, res: &Result) { - debug_assert_eq!(recording.state(), RecordingState::Finished); + async fn replace_session(&self, restore_token: Option<&str>) -> Result<()> { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); - match res { - Ok(ref recording_file) => { - let application = utils::app_instance(); - application.send_record_success_notification(recording_file); + let session = ScreencastSession::new() + .await + .context("Failed to create ScreencastSession")?; - let recent_manager = gtk::RecentManager::default(); - recent_manager.add_item(&recording_file.uri()); - } - Err(ref err) => { - if err.is::() { - tracing::debug!("{:?}", err); - } else if err.is::() { - const OPEN_RESPONSE: &str = "open"; - const LATER_RESPONSE: &str = "later"; - let d = adw::MessageDialog::builder() - .heading(gettext("Open Preferences?")) - .body(gettext("The previously selected format may have been unavailable. Open preferences and select a format to continue recording.")) - .default_response(OPEN_RESPONSE) - .transient_for(self) - .modal(true) - .build(); - d.add_response(LATER_RESPONSE, &gettext("Later")); - d.add_response(OPEN_RESPONSE, &gettext("Open")); - d.set_response_appearance(OPEN_RESPONSE, adw::ResponseAppearance::Suggested); - d.connect_response(Some(OPEN_RESPONSE), |d, _| { - d.close(); - utils::app_instance().present_preferences(); - }); - d.present(); + tracing::debug!( + version = ?session.version(), + available_cursor_modes = ?session.available_cursor_modes(), + available_source_types = ?session.available_source_types(), + "Screencast session created" + ); + + let (streams, restore_token, fd) = session + .begin( + if settings.show_pointer() { + CursorMode::EMBEDDED + } else { + CursorMode::HIDDEN + }, + if utils::is_experimental_mode() { + SourceType::MONITOR | SourceType::WINDOW } else { - tracing::error!("{:?}", err); - self.surface().beep(); - self.present_error(err); + SourceType::MONITOR + }, + true, + restore_token, + PersistMode::ExplicitlyRevoked, + Some(self), + ) + .await + .context("Failed to begin ScreencastSession")?; + settings.set_screencast_restore_token(&restore_token.unwrap_or_default()); + + self.close_session(); + + imp.pipeline.set_streams(&streams, fd)?; + imp.session.replace(Some(session)); + + Ok(()) + } + + async fn load_session(&self) -> Result<()> { + let app = utils::app_instance(); + let settings = app.settings(); + + let restore_token = settings.screencast_restore_token(); + settings.set_screencast_restore_token(""); + + self.replace_session(Some(&restore_token)).await?; + + Ok(()) + } + + fn load_window_size(&self) { + let app = utils::app_instance(); + let settings = app.settings(); + + self.set_default_size(settings.window_width(), settings.window_height()); + + if settings.window_maximized() { + self.maximize(); + } + } + + fn save_window_size(&self) -> Result<()> { + let app = utils::app_instance(); + let settings = app.settings(); + + let (width, height) = self.default_size(); + + settings.try_set_window_width(width)?; + settings.try_set_window_height(height)?; + + settings.try_set_window_maximized(self.is_maximized())?; + + Ok(()) + } + + fn update_desktop_audio_pipeline(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + if settings.record_speaker() { + glib::spawn_future_local(clone!(@weak self as obj => async move { + if let Err(err) = obj.imp().pipeline.load_desktop_audio().await { + tracing::error!("Failed to load desktop audio: {:?}", err); } + })); + } else { + if let Err(err) = imp.pipeline.unload_desktop_audio() { + tracing::error!("Failed to unload desktop audio: {:?}", err); } + + imp.desktop_audio_level_left.set_value(0.0); + imp.desktop_audio_level_right.set_value(0.0); } + } - if let Some((recording, handler_ids)) = self.imp().recording.take() { - for handler_id in handler_ids { - recording.disconnect(handler_id); - } + fn update_microphone_pipeline(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); + + if settings.record_mic() { + glib::spawn_future_local(clone!(@weak self as obj => async move { + if let Err(err) = obj.imp().pipeline.load_microphone().await { + tracing::error!("Failed to load microphone: {:?}", err); + } + })); } else { - tracing::warn!("Recording finished but no stored recording"); + if let Err(err) = imp.pipeline.unload_microphone() { + tracing::error!("Failed to unload microphone: {:?}", err); + } + + imp.microphone_level_left.set_value(0.0); + imp.microphone_level_right.set_value(0.0); } } - fn update_view(&self) { + fn update_selection_toggle_sensitivity(&self) { let imp = self.imp(); - // TODO disregard ms granularity recording state change + imp.selection_toggle + .set_sensitive(imp.pipeline.stream_size().is_some()); + } - let state = imp - .recording - .borrow() - .as_ref() - .map_or(RecordingState::Init, |(recording, _)| recording.state()); + fn update_selection_toggle(&self) { + let imp = self.imp(); - match state { - RecordingState::Init | RecordingState::Finished => { - imp.stack.set_visible_child(&*imp.main_page); + imp.selection_toggle + .set_active(imp.view_port.selection().is_some()); + } - imp.recording_time_label - .set_label(&format_time(gst::ClockTime::ZERO)); - } - RecordingState::Delayed { secs_left } => { - imp.delay_label.set_label(&secs_left.to_string()); + fn update_info_label(&self) { + let imp = self.imp(); - imp.stack.set_visible_child(&*imp.delay_page); - } - RecordingState::Recording => { - imp.pause_record_button - .set_icon_name("media-playback-pause-symbolic"); - imp.recording_label.set_label(&gettext("Recording")); - imp.recording_time_label.remove_css_class("paused"); + let app = utils::app_instance(); + let settings = app.settings(); - imp.stack.set_visible_child(&*imp.recording_page); - } - RecordingState::Paused => { - imp.pause_record_button - .set_icon_name("media-playback-start-symbolic"); - imp.recording_label.set_label(&gettext("Paused")); - imp.recording_time_label.add_css_class("paused"); + let mut info_list = vec![ + settings + .profile() + .map_or_else(|| gettext("No Profile"), |profile| profile.name()), + format!("{} FPS", settings.video_framerate()), + ]; - imp.stack.set_visible_child(&*imp.recording_page); + match (imp.pipeline.stream_size(), imp.view_port.selection()) { + (Some(stream_size), Some(selection)) => { + let paintable_rect = imp.view_port.paintable_rect().unwrap(); + let scale_factor_h = stream_size.width() as f32 / paintable_rect.width(); + let scale_factor_v = stream_size.height() as f32 / paintable_rect.height(); + let selection_rect_scaled = selection.rect().scale(scale_factor_h, scale_factor_v); + info_list.push(format!( + "{}×{}", + selection_rect_scaled.width().round() as i32, + selection_rect_scaled.height().round() as i32, + )); } - RecordingState::Flushing => imp.stack.set_visible_child(&*imp.flushing_page), + (Some(stream_size), None) => { + info_list.push(format!("{}×{}", stream_size.width(), stream_size.height())); + } + _ => {} } - self.action_set_enabled( - "win.toggle-record", - !matches!( - state, - RecordingState::Delayed { .. } | RecordingState::Flushing - ), - ); - self.action_set_enabled( - "win.toggle-pause", - matches!(state, RecordingState::Recording | RecordingState::Paused), - ); - self.action_set_enabled( - "win.cancel-record", - matches!( - state, - RecordingState::Delayed { .. } | RecordingState::Flushing - ), - ); + imp.info_label.set_label(&info_list.join(" • ")); } - fn update_title_label(&self) { + fn update_recording_ui(&self) { let imp = self.imp(); - match utils::app_instance().settings().capture_mode() { - CaptureMode::MonitorWindow => imp.title.set_title(&gettext("Normal")), - CaptureMode::Selection => imp.title.set_title(&gettext("Selection")), + match imp.pipeline.recording_state() { + RecordingState::Idle => { + if imp.delay_timer.borrow().is_some() { + imp.record_button.set_label(&gettext("Cancel")); + + imp.record_button.remove_css_class("suggested-action"); + imp.record_button.add_css_class("destructive-action"); + } else { + imp.record_button.set_label(&gettext("Record")); + + imp.record_button.remove_css_class("destructive-action"); + imp.record_button.add_css_class("suggested-action"); + } + + imp.recording_indicator.remove_css_class("red"); + imp.recording_indicator.add_css_class("dim-label"); + + imp.recording_time_label.set_label("00∶00∶00"); + } + RecordingState::Started { duration } => { + imp.record_button.set_label(&gettext("Stop")); + + imp.record_button.remove_css_class("suggested-action"); + imp.record_button.add_css_class("destructive-action"); + + imp.recording_indicator.remove_css_class("dim-label"); + imp.recording_indicator.add_css_class("red"); + + let secs = duration.seconds(); + let hours_display = secs / 3600; + let minutes_display = (secs / 60) % 60; + let seconds_display = secs % 60; + imp.recording_time_label.set_label(&format!( + "{:02}∶{:02}∶{:02}", + hours_display, minutes_display, seconds_display + )); + } } } - fn update_audio_toggles_sensitivity(&self) { - let is_enabled = utils::app_instance() - .settings() - .profile() - .map_or(true, |profile| profile.supports_audio()); + fn update_desktop_audio_level_sensitivity(&self) { + let imp = self.imp(); + + let app = utils::app_instance(); + let settings = app.settings(); - self.action_set_enabled("win.record-speaker", is_enabled); - self.action_set_enabled("win.record-mic", is_enabled); + let is_record_desktop_audio = settings.record_speaker(); + imp.desktop_audio_level_left + .set_sensitive(is_record_desktop_audio); + imp.desktop_audio_level_right + .set_sensitive(is_record_desktop_audio); } - fn update_forget_video_sources_action(&self) { - let has_restore_token = !utils::app_instance() - .settings() - .screencast_restore_token() - .is_empty(); + fn update_microphone_level_sensitivity(&self) { + let imp = self.imp(); - self.imp() - .forget_video_sources_revealer - .set_reveal_child(has_restore_token); + let app = utils::app_instance(); + let settings = app.settings(); - self.action_set_enabled("win.forget-video-sources", has_restore_token); + let is_record_microphone = settings.record_mic(); + imp.microphone_level_left + .set_sensitive(is_record_microphone); + imp.microphone_level_right + .set_sensitive(is_record_microphone); } fn setup_settings(&self) { let app = utils::app_instance(); let settings = app.settings(); - settings.connect_capture_mode_changed(clone!(@weak self as obj => move |_| { - obj.update_title_label(); - })); - - settings.connect_profile_changed(clone!(@weak self as obj => move |_| { - obj.update_audio_toggles_sensitivity(); - })); - - settings.connect_screencast_restore_token_changed(clone!(@weak self as obj => move |_| { - obj.update_forget_video_sources_action(); - })); - - self.update_title_label(); - self.update_audio_toggles_sensitivity(); - self.update_forget_video_sources_action(); - self.add_action(&settings.create_record_speaker_action()); self.add_action(&settings.create_record_mic_action()); self.add_action(&settings.create_show_pointer_action()); - self.add_action(&settings.create_capture_mode_action()); - } -} - -/// Format time in MM:SS. The MM part will be more than 2 digits -/// if the time is >= 1 hour. -fn format_time(clock_time: gst::ClockTime) -> String { - let secs = clock_time.seconds(); - - let seconds_display = secs % 60; - let minutes_display = secs / 60; - format!("{:02}∶{:02}", minutes_display, seconds_display) -} -#[cfg(test)] -mod tests { - use super::*; + settings.connect_record_speaker_changed(clone!(@weak self as obj => move |_| { + obj.update_desktop_audio_level_sensitivity(); + obj.update_desktop_audio_pipeline(); + })); + settings.connect_record_mic_changed(clone!(@weak self as obj => move |_| { + obj.update_microphone_level_sensitivity(); + obj.update_microphone_pipeline(); + })); - #[test] - fn format_time_less_than_1_hour() { - assert_eq!(format_time(gst::ClockTime::ZERO), "00∶00"); - assert_eq!(format_time(gst::ClockTime::from_seconds(31)), "00∶31"); - assert_eq!( - format_time(gst::ClockTime::from_seconds(8 * 60 + 1)), - "08∶01" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(33 * 60 + 3)), - "33∶03" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(59 * 60 + 59)), - "59∶59" - ); - } + settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { + obj.update_info_label(); + })); + settings.connect_profile_changed(clone!(@weak self as obj => move |_| { + obj.update_info_label(); + })); - #[test] - fn format_time_more_than_1_hour() { - assert_eq!(format_time(gst::ClockTime::from_seconds(60 * 60)), "60∶00"); - assert_eq!( - format_time(gst::ClockTime::from_seconds(60 * 60 + 9)), - "60∶09" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(60 * 60 + 31)), - "60∶31" - ); - assert_eq!( - format_time(gst::ClockTime::from_seconds(100 * 60 + 20)), - "100∶20" - ); + self.update_desktop_audio_level_sensitivity(); + self.update_microphone_level_sensitivity(); } }