diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart
index 50e3a64ee..2d2fbe63d 100644
--- a/flutter/lib/common/widgets/dialog.dart
+++ b/flutter/lib/common/widgets/dialog.dart
@@ -1245,30 +1245,45 @@ void showConfirmSwitchSidesDialog(
 
 customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
   double qualityInitValue = 50;
+  double fpsInitValue = 30;
   bool qualitySet = false;
+  bool fpsSet = false;
 
   bool? direct;
   try {
     direct =
         ConnectionTypeState.find(id).direct.value == ConnectionType.strDirect;
   } catch (_) {}
+  bool hideFps = (await bind.mainIsUsingPublicServer() && direct != true) ||
+      versionCmp(ffi.ffiModel.pi.version, '1.2.0') < 0;
   bool hideMoreQuality =
       (await bind.mainIsUsingPublicServer() && direct != true) ||
           versionCmp(ffi.ffiModel.pi.version, '1.2.2') < 0;
 
-  setCustomValues({double? quality}) async {
+  setCustomValues({double? quality, double? fps}) async {
     if (quality != null) {
       qualitySet = true;
       await bind.sessionSetCustomImageQuality(
           sessionId: sessionId, value: quality.toInt());
       print("quality:$quality");
     }
+    if (fps != null) {
+      fpsSet = true;
+      await bind.sessionSetCustomFps(sessionId: sessionId, fps: fps.toInt());
+      print("fps:$fps");
+    }
     if (!qualitySet) {
       qualitySet = true;
       await bind.sessionSetCustomImageQuality(
           sessionId: sessionId, value: qualityInitValue.toInt());
       print("qualityInitValue:$qualityInitValue");
     }
+    if (!hideFps && !fpsSet) {
+      fpsSet = true;
+      await bind.sessionSetCustomFps(
+          sessionId: sessionId, fps: fpsInitValue.toInt());
+      print("fpsInitValue:$fpsInitValue");
+    }
   }
 
   final btnClose = dialogButton('Close', onPressed: () async {
@@ -1280,10 +1295,25 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
   final quality = await bind.sessionGetCustomImageQuality(sessionId: sessionId);
   qualityInitValue =
       quality != null && quality.isNotEmpty ? quality[0].toDouble() : 50.0;
+  if ((hideMoreQuality && qualityInitValue > 100) ||
+      qualityInitValue < 10 ||
+      qualityInitValue > 2000) {
+    qualityInitValue = 50;
+  }
+  // fps
+  final fpsOption =
+      await bind.sessionGetOption(sessionId: sessionId, arg: 'custom-fps');
+  fpsInitValue = fpsOption == null ? 30 : double.tryParse(fpsOption) ?? 30;
+  if (fpsInitValue < 5 || fpsInitValue > 120) {
+    fpsInitValue = 30;
+  }
 
   final content = customImageQualityWidget(
       initQuality: qualityInitValue,
+      initFps: fpsInitValue,
       setQuality: (v) => setCustomValues(quality: v),
+      setFps: (v) => setCustomValues(fps: v),
+      showFps: !hideFps,
       showMoreQuality: !hideMoreQuality);
   msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
 }
diff --git a/flutter/lib/common/widgets/setting_widgets.dart b/flutter/lib/common/widgets/setting_widgets.dart
index bee2fce18..26fd219ed 100644
--- a/flutter/lib/common/widgets/setting_widgets.dart
+++ b/flutter/lib/common/widgets/setting_widgets.dart
@@ -7,13 +7,16 @@ import 'package:get/get.dart';
 
 customImageQualityWidget(
     {required double initQuality,
+    required double initFps,
     required Function(double) setQuality,
+    required Function(double) setFps,
+    required bool showFps,
     required bool showMoreQuality}) {
-  final maxValue = showMoreQuality ? 2000 : 10;
-  if (initQuality < 10 || initQuality > maxValue) {
+  if (!showMoreQuality && initQuality > 100) {
     initQuality = 50;
   }
   final qualityValue = initQuality.obs;
+  final fpsValue = initFps.obs;
 
   final RxBool moreQualityChecked = RxBool(qualityValue.value > 100);
   final debouncerQuality = Debouncer<double>(
@@ -23,6 +26,13 @@ customImageQualityWidget(
     },
     initialValue: qualityValue.value,
   );
+  final debouncerFps = Debouncer<double>(
+    Duration(milliseconds: 1000),
+    onChanged: (double v) {
+      setFps(v);
+    },
+    initialValue: fpsValue.value,
+  );
 
   onMoreChanged(bool? value) {
     if (value == null) return;
@@ -96,21 +106,65 @@ customImageQualityWidget(
                 )
               ],
             )),
+      if (showFps)
+        Obx(() => Row(
+              children: [
+                Expanded(
+                  flex: 3,
+                  child: Slider(
+                    value: fpsValue.value,
+                    min: 5.0,
+                    max: 120.0,
+                    divisions: 23,
+                    onChanged: (double value) async {
+                      fpsValue.value = value;
+                      debouncerFps.value = value;
+                    },
+                  ),
+                ),
+                Expanded(
+                    flex: 1,
+                    child: Text(
+                      '${fpsValue.value.round()}',
+                      style: const TextStyle(fontSize: 15),
+                    )),
+                Expanded(
+                    flex: 2,
+                    child: Text(
+                      translate('FPS'),
+                      style: const TextStyle(fontSize: 15),
+                    ))
+              ],
+            )),
     ],
   );
 }
 
 customImageQualitySetting() {
   final qualityKey = 'custom_image_quality';
+  final fpsKey = 'custom-fps';
 
   var initQuality =
       (double.tryParse(bind.mainGetUserDefaultOption(key: qualityKey)) ?? 50.0);
+  if (initQuality < 10 || initQuality > 2000) {
+    initQuality = 50;
+  }
+  var initFps =
+      (double.tryParse(bind.mainGetUserDefaultOption(key: fpsKey)) ?? 30.0);
+  if (initFps < 5 || initFps > 120) {
+    initFps = 30;
+  }
 
   return customImageQualityWidget(
       initQuality: initQuality,
+      initFps: initFps,
       setQuality: (v) {
         bind.mainSetUserDefaultOption(key: qualityKey, value: v.toString());
       },
+      setFps: (v) {
+        bind.mainSetUserDefaultOption(key: fpsKey, value: v.toString());
+      },
+      showFps: true,
       showMoreQuality: true);
 }
 
diff --git a/libs/hbb_common/src/config.rs b/libs/hbb_common/src/config.rs
index 1ae46f145..b094e230c 100644
--- a/libs/hbb_common/src/config.rs
+++ b/libs/hbb_common/src/config.rs
@@ -1218,6 +1218,10 @@ impl PeerConfig {
         if !mp.contains_key(key) {
             mp.insert(key.to_owned(), UserDefaultConfig::read().get(key));
         }
+        key = "custom-fps";
+        if !mp.contains_key(key) {
+            mp.insert(key.to_owned(), UserDefaultConfig::read().get(key));
+        }
         key = "zoom-cursor";
         if !mp.contains_key(key) {
             mp.insert(key.to_owned(), UserDefaultConfig::read().get(key));
@@ -1520,6 +1524,7 @@ impl UserDefaultConfig {
                 self.get_string(key, "auto", vec!["vp8", "vp9", "av1", "h264", "h265"])
             }
             "custom_image_quality" => self.get_double_string(key, 50.0, 10.0, 0xFFF as f64),
+            "custom-fps" => self.get_double_string(key, 30.0, 5.0, 120.0),
             _ => self
                 .options
                 .get(key)
diff --git a/src/client.rs b/src/client.rs
index 3da1b0450..14d0e62c4 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -1483,6 +1483,10 @@ impl LoginConfigHandler {
                 config.custom_image_quality[0]
             };
             msg.custom_image_quality = quality << 8;
+            #[cfg(feature = "flutter")]
+            if let Some(custom_fps) = self.options.get("custom-fps") {
+                msg.custom_fps = custom_fps.parse().unwrap_or(30);
+            }
             n += 1;
         }
         let view_only = self.get_toggle_option("view-only");
@@ -1663,6 +1667,27 @@ impl LoginConfigHandler {
         res
     }
 
+    /// Create a [`Message`] for saving custom fps.
+    ///
+    /// # Arguments
+    ///
+    /// * `fps` - The given fps.
+    pub fn set_custom_fps(&mut self, fps: i32) -> Message {
+        let mut misc = Misc::new();
+        misc.set_option(OptionMessage {
+            custom_fps: fps,
+            ..Default::default()
+        });
+        let mut msg_out = Message::new();
+        msg_out.set_misc(misc);
+        let mut config = self.load_config();
+        config
+            .options
+            .insert("custom-fps".to_owned(), fps.to_string());
+        self.save_config(config);
+        msg_out
+    }
+
     pub fn get_option(&self, k: &str) -> String {
         if let Some(v) = self.config.options.get(k) {
             v.clone()
@@ -1770,11 +1795,7 @@ impl LoginConfigHandler {
             crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, evt);
         }
         if config.keyboard_mode.is_empty() {
-            if is_keyboard_mode_supported(
-                &KeyboardMode::Map,
-                get_version_number(&pi.version),
-                &pi.platform,
-            ) {
+            if is_keyboard_mode_supported(&KeyboardMode::Map, get_version_number(&pi.version), &pi.platform) {
                 config.keyboard_mode = KeyboardMode::Map.to_string();
             } else {
                 config.keyboard_mode = KeyboardMode::Legacy.to_string();
diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs
index 0e39d7c3f..436a95587 100644
--- a/src/flutter_ffi.rs
+++ b/src/flutter_ffi.rs
@@ -395,7 +395,7 @@ pub fn session_is_keyboard_mode_supported(session_id: SessionID, mode: String) -
             SyncReturn(is_keyboard_mode_supported(
                 &mode,
                 session.get_peer_version(),
-                &session.peer_platform(),
+                &session.peer_platform()
             ))
         } else {
             SyncReturn(false)
@@ -411,6 +411,12 @@ pub fn session_set_custom_image_quality(session_id: SessionID, value: i32) {
     }
 }
 
+pub fn session_set_custom_fps(session_id: SessionID, fps: i32) {
+    if let Some(session) = sessions::get_session_by_session_id(&session_id) {
+        session.set_custom_fps(fps);
+    }
+}
+
 pub fn session_lock_screen(session_id: SessionID) {
     if let Some(session) = sessions::get_session_by_session_id(&session_id) {
         session.lock_screen();
diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs
index fbfc0c9e6..c82b91029 100644
--- a/src/ui_session_interface.rs
+++ b/src/ui_session_interface.rs
@@ -1,7 +1,4 @@
-use crate::{
-    common::{get_supported_keyboard_modes, is_keyboard_mode_supported},
-    input::{MOUSE_BUTTON_LEFT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP, MOUSE_TYPE_WHEEL},
-};
+use crate::{input::{MOUSE_BUTTON_LEFT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP, MOUSE_TYPE_WHEEL}, common::{is_keyboard_mode_supported, get_supported_keyboard_modes}};
 use async_trait::async_trait;
 use bytes::Bytes;
 use rdev::{Event, EventType::*, KeyCode};
@@ -216,7 +213,7 @@ impl<T: InvokeUiSession> Session<T> {
         self.lc.read().unwrap().version.clone()
     }
 
-    pub fn fallback_keyboard_mode(&self) -> String {
+    pub fn fallback_keyboard_mode(&self) -> String { 
         let peer_version = self.get_peer_version();
         let platform = self.peer_platform();
 
@@ -395,6 +392,11 @@ impl<T: InvokeUiSession> Session<T> {
         }
     }
 
+    pub fn set_custom_fps(&self, custom_fps: i32) {
+        let msg = self.lc.write().unwrap().set_custom_fps(custom_fps);
+        self.send(Data::Message(msg));
+    }
+
     pub fn get_remember(&self) -> bool {
         self.lc.read().unwrap().remember
     }