diff --git a/Cargo.lock b/Cargo.lock index bdafe9af..2a70c6c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,6 +954,8 @@ dependencies = [ "libadwaita", "libpulse-binding", "libpulse-glib-binding", + "num-rational", + "num-traits", "once_cell", "serde", "serde_yaml", @@ -1114,6 +1116,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-derive" version = "0.3.3" @@ -1141,6 +1154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" dependencies = [ "autocfg", + "num-bigint", "num-integer", "num-traits", ] diff --git a/Cargo.toml b/Cargo.toml index d5fb0c3f..695ecb27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ gst = { package = "gstreamer", version = "0.22" } gst-plugin-gif = "0.12" gst-plugin-gtk4 = { version = "0.12", features = ["gtk_v4_14"] } gtk = { package = "gtk4", version = "0.8", features = ["v4_14"] } +num-rational = "0.4" +num-traits = "0.2" once_cell = "1.19.0" pulse = { package = "libpulse-binding", version = "2.26.0" } pulse_glib = { package = "libpulse-glib-binding", version = "2.25.1" } diff --git a/data/io.github.seadve.Kooha.gschema.xml.in b/data/io.github.seadve.Kooha.gschema.xml.in index 972b2df8..52b04acb 100644 --- a/data/io.github.seadve.Kooha.gschema.xml.in +++ b/data/io.github.seadve.Kooha.gschema.xml.in @@ -26,8 +26,8 @@ b"" - - 30 + + (30, 1) "" diff --git a/data/resources/ui/preferences-dialog.ui b/data/resources/ui/preferences-dialog.ui index eaee9e5c..6d91b289 100644 --- a/data/resources/ui/preferences-dialog.ui +++ b/data/resources/ui/preferences-dialog.ui @@ -54,35 +54,8 @@ - + Frame Rate - - - 12 - - - This frame rate may cause performance issues. - warning-symbolic - - - - - - center - - - 0 - 120 - 1 - 5 - - - - - - diff --git a/src/area_selector/mod.rs b/src/area_selector/mod.rs index 0cf35927..f4ff2169 100644 --- a/src/area_selector/mod.rs +++ b/src/area_selector/mod.rs @@ -14,9 +14,14 @@ use std::{cell::RefCell, os::unix::prelude::RawFd}; pub use self::view_port::Selection; use self::view_port::ViewPort; -use crate::{application::Application, cancelled::Cancelled, pipeline, screencast_session::Stream}; +use crate::{ + application::Application, + cancelled::Cancelled, + pipeline::{self, Framerate}, + screencast_session::Stream, +}; -const PREVIEW_FRAMERATE: u32 = 60; +const PREVIEW_FRAMERATE: Framerate = Framerate::new_raw(60, 1); const WINDOW_TO_MONITOR_SCALE_FACTOR: f64 = 0.4; // We can't get header bar height before the window is presented, so we assume "46" as the default. diff --git a/src/pipeline.rs b/src/pipeline.rs index dbe71241..0da4347b 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Context, Ok, Result}; use gst::prelude::*; use gtk::graphene::Rect; +use num_rational::Rational32; use std::{ os::unix::io::RawFd, @@ -12,11 +13,13 @@ use crate::{area_selector::SelectAreaData, profile::Profile, screencast_session: const AUDIO_SAMPLE_RATE: i32 = 48_000; const AUDIO_N_CHANNELS: i32 = 1; +pub type Framerate = Rational32; + #[derive(Debug)] #[must_use] pub struct PipelineBuilder { file_path: PathBuf, - framerate: u32, + framerate: Framerate, profile: Profile, fd: RawFd, streams: Vec, @@ -28,7 +31,7 @@ pub struct PipelineBuilder { impl PipelineBuilder { pub fn new( file_path: &Path, - framerate: u32, + framerate: Framerate, profile: Profile, fd: RawFd, streams: Vec, @@ -63,7 +66,7 @@ impl PipelineBuilder { pub fn build(&self) -> Result { tracing::debug!( file_path = %self.file_path.display(), - framerate = self.framerate, + framerate = ?self.framerate, profile = ?self.profile.id(), stream_len = self.streams.len(), streams = ?self.streams, @@ -226,13 +229,13 @@ fn make_videocrop(data: &SelectAreaData) -> Result { pub fn make_pipewiresrc_bin( fd: RawFd, streams: &[Stream], - framerate: u32, + framerate: Framerate, select_area_data: Option<&SelectAreaData>, ) -> Result { let bin = gst::Bin::builder().name("kooha-pipewiresrc-bin").build(); let videorate_caps = gst::Caps::builder("video/x-raw") - .field("framerate", gst::Fraction::new(framerate as i32, 1)) + .field("framerate", gst::Fraction::from(framerate)) .build(); let src_element = match streams { diff --git a/src/preferences_dialog.rs b/src/preferences_dialog.rs index 2a7380e1..62f1e362 100644 --- a/src/preferences_dialog.rs +++ b/src/preferences_dialog.rs @@ -1,19 +1,103 @@ -use std::path::Path; +use std::{fmt, path::Path}; use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use gtk::{ gio, - glib::{self, clone, closure, BoxedAnyObject}, + glib::{self, clone, closure, translate::FromGlib, BoxedAnyObject}, }; +use num_traits::Signed; -use crate::{item_row::ItemRow, profile::Profile, settings::Settings, IS_EXPERIMENTAL_MODE}; +use crate::{ + item_row::ItemRow, pipeline::Framerate, profile::Profile, settings::Settings, + IS_EXPERIMENTAL_MODE, +}; + +const ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY: &str = "kooha-row-selected-item-notify-handler-id"; +const SETTINGS_PROFILE_CHANGED_HANDLER_ID_KEY: &str = "kooha-settings-profile-changed-handler-id"; /// Used to represent "none" profile in the profiles model type NoneProfile = BoxedAnyObject; -const PROFILE_ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY: &str = - "kooha-profile-row-selected-item-notify-handler-id"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Enum)] +#[enum_type(name = "KoohaFramerateOption")] +pub enum FramerateOption { + _10, + _20, + _23_976, + _24, + _25, + _29_97, + _30, + _48, + _50, + _59_94, + _60, +} + +impl FramerateOption { + /// Returns the closest `FramerateOption` to the given `Framerate`. + pub fn from_framerate(framerate: Framerate) -> Self { + let all = [ + Self::_10, + Self::_20, + Self::_23_976, + Self::_24, + Self::_25, + Self::_29_97, + Self::_30, + Self::_48, + Self::_50, + Self::_59_94, + Self::_60, + ]; + + *all.iter() + .min_by(|a, b| { + (a.to_framerate() - framerate) + .abs() + .cmp(&(b.to_framerate() - framerate).abs()) + }) + .unwrap() + } + + /// Converts a `FramerateOption` to a `Framerate`. + pub fn to_framerate(self) -> Framerate { + let (numer, denom) = match self { + Self::_10 => (10, 1), + Self::_20 => (20, 1), + Self::_23_976 => (24_000, 1001), + Self::_24 => (24, 1), + Self::_25 => (25, 1), + Self::_29_97 => (30_000, 1001), + Self::_30 => (30, 1), + Self::_48 => (48, 1), + Self::_50 => (50, 1), + Self::_59_94 => (60_000, 1001), + Self::_60 => (60, 1), + }; + Framerate::new(numer, denom) + } +} + +impl fmt::Display for FramerateOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::_10 => "10", + Self::_20 => "20", + Self::_23_976 => "23.976", + Self::_24 => "24 NTSC", + Self::_25 => "25 PAL", + Self::_29_97 => "29.97", + Self::_30 => "30", + Self::_48 => "48", + Self::_50 => "50 PAL", + Self::_59_94 => "59.94", + Self::_60 => "60", + }; + f.write_str(name) + } +} mod imp { use std::cell::OnceCell; @@ -28,13 +112,11 @@ mod imp { #[property(get, set, construct_only)] pub(super) settings: OnceCell, - #[template_child] - pub(super) framerate_button: TemplateChild, - #[template_child] - pub(super) framerate_warning: TemplateChild, #[template_child] pub(super) profile_row: TemplateChild, #[template_child] + pub(super) framerate_row: TemplateChild, + #[template_child] pub(super) delay_button: TemplateChild, #[template_child] pub(super) file_chooser_button_content: TemplateChild, @@ -83,14 +165,67 @@ mod imp { let obj = self.obj(); let settings = obj.settings(); - let active_profile = settings.profile(); - self.profile_row - .set_factory(Some(&profile_row_factory(&self.profile_row))); + self.framerate_row.set_factory(Some(&row_factory( + &self.framerate_row, + &gettext("This frame rate may cause performance issues on the selected format."), + clone!(@strong settings => move |list_item| { + let item = list_item.item().unwrap(); + let item_row = list_item.child().unwrap().downcast::().unwrap(); + + let enum_list_item = item.downcast_ref::().unwrap(); + let framerate_option = unsafe { FramerateOption::from_glib(enum_list_item.value()) }; + item_row.set_title(framerate_option.to_string()); + + unsafe { + list_item.set_data( + SETTINGS_PROFILE_CHANGED_HANDLER_ID_KEY, + settings.connect_profile_changed( + clone!(@weak list_item => move |settings| { + update_item_row_shows_warning_icon(settings, &list_item); + }), + ), + ); + } + + update_item_row_shows_warning_icon(&settings, list_item); + }), + clone!(@strong settings => move |list_item| { + unsafe { + let handler_id = list_item + .steal_data(SETTINGS_PROFILE_CHANGED_HANDLER_ID_KEY) + .unwrap(); + settings.disconnect(handler_id); + } + }), + ))); + self.framerate_row.set_model(Some(&adw::EnumListModel::new( + FramerateOption::static_type(), + ))); + + self.profile_row.set_factory(Some(&row_factory( + &self.profile_row, + &gettext(gettext("This format is experimental and unsupported.")), + |list_item| { + let item = list_item.item().unwrap(); + let item_row = list_item.child().unwrap().downcast::().unwrap(); + + let profile = profile_from_obj(&item); + item_row.set_title( + profile + .map_or_else(|| gettext("None"), |profile| profile.name().to_string()), + ); + item_row.set_shows_warning_icon( + profile.is_some_and(|profile| profile.is_experimental()), + ); + }, + |_| {}, + ))); let profiles = Profile::all() .inspect_err(|err| tracing::error!("Failed to load profiles: {:?}", err)) .unwrap_or_default(); let profiles_model = gio::ListStore::new::(); + let active_profile = settings.profile(); if active_profile.is_none() { profiles_model.append(&NoneProfile::new(())); } @@ -116,12 +251,8 @@ mod imp { .bind_record_delay(&self.delay_button.get(), "value") .build(); - settings - .bind_video_framerate(&self.framerate_button.get(), "value") - .build(); - - settings.connect_video_framerate_changed(clone!(@weak obj => move |_| { - obj.update_framerate_warning(); + settings.connect_framerate_changed(clone!(@weak obj => move |_| { + obj.update_framerate_row(); })); settings.connect_saving_location_changed(clone!(@weak obj => move |_| { @@ -130,15 +261,14 @@ mod imp { settings.connect_profile_changed(clone!(@weak obj => move |_| { obj.update_profile_row(); - obj.update_framerate_warning(); })); obj.update_file_chooser_button(); - obj.update_framerate_warning(); obj.update_profile_row(); + obj.update_framerate_row(); - // Load last active profile first in `update_profile_row` before - // connecting to the signal to avoid unnecessary updates. + // Load last active value first in `update_*_row` before connecting to + // the signal to avoid unnecessary updates. self.profile_row .connect_selected_item_notify(clone!(@weak obj => move |row| { if let Some(item) = row.selected_item() { @@ -146,6 +276,14 @@ mod imp { obj.settings().set_profile(profile); } })); + self.framerate_row + .connect_selected_item_notify(clone!(@weak obj => move |row| { + if let Some(item) = row.selected_item() { + let enum_list_item = item.downcast_ref::().unwrap(); + let framerate_option = unsafe { FramerateOption::from_glib(enum_list_item.value()) }; + obj.settings().set_framerate(framerate_option.to_framerate()); + } + })); } } @@ -175,10 +313,11 @@ impl PreferencesDialog { } fn update_profile_row(&self) { + let imp = self.imp(); + let settings = self.settings(); let active_profile = settings.profile(); - let imp = self.imp(); let position = imp .profile_row .model() @@ -191,7 +330,6 @@ impl PreferencesDialog { _ => false, }, ); - if let Some(position) = position { imp.profile_row.set_selected(position as u32); } else { @@ -202,77 +340,91 @@ impl PreferencesDialog { } } - fn update_framerate_warning(&self) { + fn update_framerate_row(&self) { let imp = self.imp(); + let settings = self.settings(); + let framerate_option = FramerateOption::from_framerate(settings.framerate()); - imp.framerate_warning.set_visible( - settings.profile().is_some_and(|profile| { - settings.video_framerate() > profile.suggested_max_framerate() - }), - ); + let position = imp + .framerate_row + .model() + .unwrap() + .into_iter() + .position(|item| { + let item = item.unwrap(); + let enum_list_item = item.downcast::().unwrap(); + enum_list_item.value() == framerate_option as i32 + }); + if let Some(position) = position { + imp.framerate_row.set_selected(position as u32); + } else { + tracing::error!( + "Active framerate `{:?}` was not found on framerate model", + framerate_option + ); + } } } -fn profile_row_factory(profile_row: &adw::ComboRow) -> gtk::SignalListItemFactory { +fn row_factory( + row: &adw::ComboRow, + warning_tooltip_text: &str, + bind_cb: impl Fn(>k::ListItem) + 'static, + unbind_cb: impl Fn(>k::ListItem) + 'static, +) -> gtk::SignalListItemFactory { let factory = gtk::SignalListItemFactory::new(); - factory.connect_setup(clone!(@weak profile_row => move |_, list_item| { + let warning_tooltip_text = warning_tooltip_text.to_string(); + factory.connect_setup(clone!(@weak row => move |_, list_item| { let list_item = list_item.downcast_ref::().unwrap(); let item_row = ItemRow::new(); - item_row.set_warning_tooltip_text(gettext("This format is experimental and unsupported.")); + item_row.set_warning_tooltip_text(warning_tooltip_text.as_str()); list_item.set_child(Some(&item_row)); })); - factory.connect_bind(clone!(@weak profile_row => move |_, list_item| { + factory.connect_bind(clone!(@weak row => move |_, list_item| { let list_item = list_item.downcast_ref::().unwrap(); let item_row = list_item.child().unwrap().downcast::().unwrap(); - let item = list_item.item().unwrap(); - let profile = profile_from_obj(&item); - - item_row.set_shows_warning_icon(profile.is_some_and(|profile| profile.is_experimental())); - item_row.set_title( - profile.map_or_else(|| gettext("None"), |profile| profile.name().to_string()), - ); - // Only show the selected icon when it is inside the given row's popover. This assumes that // the parent of the given row is not a popover, so we can tell which is which. if item_row.ancestor(gtk::Popover::static_type()).is_some() { - debug_assert!(profile_row.ancestor(gtk::Popover::static_type()).is_none()); + debug_assert!(row.ancestor(gtk::Popover::static_type()).is_none()); item_row.set_shows_selected_icon(true); unsafe { list_item.set_data( - PROFILE_ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY, - profile_row.connect_selected_item_notify( - clone!(@weak list_item => move |profile_row| { - update_item_row_is_selected(profile_row, &list_item); - }), - ), + ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY, + row.connect_selected_item_notify(clone!(@weak list_item => move |row| { + update_item_row_is_selected(row, &list_item); + })), ); } - update_item_row_is_selected(&profile_row, list_item); + update_item_row_is_selected(&row, list_item); } else { item_row.set_shows_selected_icon(false); } + + bind_cb(list_item); })); - factory.connect_unbind(clone!(@weak profile_row => move |_, list_item| { + factory.connect_unbind(clone!(@weak row => move |_, list_item| { let list_item = list_item.downcast_ref::().unwrap(); unsafe { - if let Some(handler_id) = - list_item.steal_data(PROFILE_ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY) + if let Some(handler_id) = list_item.steal_data(ROW_SELECTED_ITEM_NOTIFY_HANDLER_ID_KEY) { - profile_row.disconnect(handler_id); + row.disconnect(handler_id); } } + + unbind_cb(list_item); })); factory @@ -284,6 +436,19 @@ fn update_item_row_is_selected(row: &adw::ComboRow, list_item: >k::ListItem) { item_row.set_is_selected(row.selected_item() == list_item.item()); } +fn update_item_row_shows_warning_icon(settings: &Settings, list_item: >k::ListItem) { + let item_row = list_item.child().unwrap().downcast::().unwrap(); + let item = list_item.item().unwrap(); + + let enum_list_item = item.downcast_ref::().unwrap(); + + let framerate_option = unsafe { FramerateOption::from_glib(enum_list_item.value()) }; + + item_row.set_shows_warning_icon(settings.profile().is_some_and(|profile| { + framerate_option.to_framerate() > profile.suggested_max_framerate() + })); +} + /// Returns `Some` if the object is a `Profile`, otherwise `None`, if the object is a `NoneProfile`. fn profile_from_obj(obj: &glib::Object) -> Option<&Profile> { if let Some(profile) = obj.downcast_ref::() { @@ -315,3 +480,64 @@ fn display_path(path: &Path) -> String { path_display } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn framerate_option() { + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(5)), + FramerateOption::_10 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(10)), + FramerateOption::_10 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(20)), + FramerateOption::_20 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::approximate_float(23.976).unwrap()), + FramerateOption::_23_976 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(24)), + FramerateOption::_24 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(25)), + FramerateOption::_25 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::approximate_float(29.97).unwrap()), + FramerateOption::_29_97 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(30)), + FramerateOption::_30 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(48)), + FramerateOption::_48 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(50)), + FramerateOption::_50 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::approximate_float(59.94).unwrap()), + FramerateOption::_59_94 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(60)), + FramerateOption::_60 + ); + assert_eq!( + FramerateOption::from_framerate(Framerate::from_integer(120)), + FramerateOption::_60 + ); + } +} diff --git a/src/profile.rs b/src/profile.rs index 65193d71..add2c322 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -7,7 +7,9 @@ use gtk::{ use once_cell::sync::OnceCell as OnceLock; use serde::Deserialize; -const DEFAULT_SUGGESTED_MAX_FRAMERATE: u32 = 60; +use crate::pipeline::Framerate; + +const DEFAULT_SUGGESTED_MAX_FRAMERATE: Framerate = Framerate::new_raw(30, 1); const MAX_THREAD_COUNT: u32 = 64; #[derive(Debug, Deserialize)] @@ -23,7 +25,7 @@ struct ProfileData { is_experimental: bool, name: String, #[serde(rename = "suggested-max-fps")] - suggested_max_framerate: Option, + suggested_max_framerate: Option, #[serde(rename = "extension")] file_extension: String, #[serde(rename = "videoenc")] @@ -113,10 +115,11 @@ impl Profile { self.data().audioenc_bin_str.is_some() } - pub fn suggested_max_framerate(&self) -> u32 { - self.data() - .suggested_max_framerate - .unwrap_or(DEFAULT_SUGGESTED_MAX_FRAMERATE) + pub fn suggested_max_framerate(&self) -> Framerate { + self.data().suggested_max_framerate.map_or_else( + || DEFAULT_SUGGESTED_MAX_FRAMERATE, + |raw| Framerate::approximate_float(raw).unwrap(), + ) } pub fn is_experimental(&self) -> bool { @@ -268,7 +271,10 @@ mod tests { assert!(!profile.name().is_empty()); assert!(!profile.file_extension().is_empty()); - assert_ne!(profile.suggested_max_framerate(), 0); + assert_ne!( + profile.suggested_max_framerate(), + Framerate::from_integer(0) + ); assert!( unique.insert(profile.id().to_string()), diff --git a/src/recording.rs b/src/recording.rs index ef8379b1..c5120121 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -198,7 +198,7 @@ impl Recording { let file_path = new_recording_path(&settings.saving_location(), profile.file_extension()); let mut pipeline_builder = PipelineBuilder::new( &file_path, - settings.video_framerate(), + settings.framerate(), profile.clone(), fd, streams.clone(), diff --git a/src/settings.rs b/src/settings.rs index 56bdfa6d..3bc0e988 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,6 +9,7 @@ use gtk::{gio, glib}; use crate::{ area_selector::{Selection, SelectionContext}, config::APP_ID, + pipeline::Framerate, profile::Profile, }; @@ -20,6 +21,7 @@ use crate::{ ret_type = "SelectionContext" )] #[gen_settings_skip(key_name = "saving-location")] +#[gen_settings_skip(key_name = "framerate")] #[gen_settings_skip(key_name = "record-delay")] #[gen_settings_skip(key_name = "profile-id")] pub struct Settings; @@ -84,6 +86,26 @@ impl Settings { }) } + pub fn framerate(&self) -> Framerate { + self.0.get::<(i32, i32)>("framerate").into() + } + + pub fn set_framerate(&self, framerate: Framerate) { + self.0 + .set("framerate", (framerate.numer(), framerate.denom())) + .unwrap(); + } + + pub fn connect_framerate_changed( + &self, + f: impl Fn(&Self) + 'static, + ) -> gio::glib::SignalHandlerId { + self.0 + .connect_changed(Some("framerate"), move |settings, _| { + f(&Self(settings.clone())); + }) + } + pub fn record_delay(&self) -> Duration { Duration::from_secs(self.0.get::("record-delay") as u64) } diff --git a/src/window/mod.rs b/src/window/mod.rs index a97b5aa9..a94955ea 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -18,6 +18,7 @@ use crate::{ config::PROFILE, format_time, help::Help, + preferences_dialog::FramerateOption, recording::{NoProfileError, Recording, RecordingState}, settings::CaptureMode, Application, @@ -517,10 +518,10 @@ impl Window { let profile_text = settings .profile() .map_or_else(|| gettext("None"), |profile| profile.name().to_string()); - let fps_text = settings.video_framerate().to_string(); + let framerate_option = FramerateOption::from_framerate(settings.framerate()); imp.title - .set_subtitle(&format!("{} • {} FPS", profile_text, fps_text)); + .set_subtitle(&format!("{} • {}", profile_text, framerate_option)); } fn update_audio_actions(&self) { @@ -559,7 +560,7 @@ impl Window { obj.update_subtitle_label(); })); - settings.connect_video_framerate_changed(clone!(@weak self as obj => move |_| { + settings.connect_framerate_changed(clone!(@weak self as obj => move |_| { obj.update_subtitle_label(); }));