diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 3e219ee91..74d3f7a3f 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -25,6 +25,7 @@ import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; +import '../widgets/custom_scale_widget.dart'; final initText = '1' * 1024; @@ -1201,6 +1202,14 @@ void showOptions( if (v != null) viewStyle.value = v; } : null)), + // Show custom scale controls when custom view style is selected + Obx(() => viewStyle.value == kRemoteViewStyleCustom + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, // Absorb tap events to prevent dialog dismiss + child: MobileCustomScaleControls(ffi: gFFI), + ) + : SizedBox.shrink()), const Divider(color: MyTheme.border), for (var e in imageQualityRadios) Obx(() => getRadio( diff --git a/flutter/lib/mobile/widgets/custom_scale_widget.dart b/flutter/lib/mobile/widgets/custom_scale_widget.dart new file mode 100644 index 000000000..7807d3e2d --- /dev/null +++ b/flutter/lib/mobile/widgets/custom_scale_widget.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:debounce_throttle/debounce_throttle.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/utils/scale.dart'; +import 'package:flutter_hbb/common.dart'; + +class MobileCustomScaleControls extends StatefulWidget { + final FFI ffi; + final ValueChanged? onChanged; + const MobileCustomScaleControls({Key? key, required this.ffi, this.onChanged}) : super(key: key); + + @override + State createState() => _MobileCustomScaleControlsState(); +} + +class _MobileCustomScaleControlsState extends State { + late int _value; + late final Debouncer _debouncerScale; + // Normalized slider position in [0, 1]. We map it nonlinearly to percent. + double _pos = 0.0; + + // Piecewise mapping constants (from consts.dart) + static const int _minPercent = kScaleCustomMinPercent; + static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track + static const int _maxPercent = kScaleCustomMaxPercent; + static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100% + static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%) + + // Clamp helper for local use + int _clamp(int v) => clampCustomScalePercent(v); + + // Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width. + int _mapPosToPercent(double p) { + if (p <= 0.0) return _minPercent; + if (p >= 1.0) return _maxPercent; + if (p <= _pivotPos) { + final q = p / _pivotPos; // 0..1 + final v = _minPercent + q * (_pivotPercent - _minPercent); + return _clamp(v.round()); + } else { + final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1 + final v = _pivotPercent + q * (_maxPercent - _pivotPercent); + return _clamp(v.round()); + } + } + + // Map percent [5,1000] → normalized position [0,1] + double _mapPercentToPos(int percent) { + final p = _clamp(percent); + if (p <= _pivotPercent) { + final q = (p - _minPercent) / (_pivotPercent - _minPercent); + return q * _pivotPos; + } else { + final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent); + return _pivotPos + q * (1.0 - _pivotPos); + } + } + + // Snap normalized position to the pivot when close to it + double _snapNormalizedPos(double p) { + if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos; + if (p < 0.0) return 0.0; + if (p > 1.0) return 1.0; + return p; + } + + @override + void initState() { + super.initState(); + _value = 100; + _debouncerScale = Debouncer( + kDebounceCustomScaleDuration, + onChanged: (v) async { + await _apply(v); + }, + initialValue: _value, + ); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final v = await getSessionCustomScalePercent(widget.ffi.sessionId); + if (mounted) { + setState(() { + _value = v; + _pos = _mapPercentToPos(v); + }); + } + } catch (e, st) { + debugPrint('[CustomScale] Failed to get initial value: $e'); + debugPrintStack(stackTrace: st); + } + }); + } + + Future _apply(int v) async { + v = clampCustomScalePercent(v); + setState(() { + _value = v; + }); + try { + await bind.sessionSetFlutterOption( + sessionId: widget.ffi.sessionId, + k: kCustomScalePercentKey, + v: v.toString()); + final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId); + if (curStyle != kRemoteViewStyleCustom) { + await bind.sessionSetViewStyle( + sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom); + } + await widget.ffi.canvasModel.updateViewStyle(); + if (isMobile) { + HapticFeedback.selectionClick(); + } + widget.onChanged?.call(v); + } catch (e, st) { + debugPrint('[CustomScale] Apply failed: $e'); + debugPrintStack(stackTrace: st); + } + } + + void _nudge(int delta) { + final next = _clamp(_value + delta); + setState(() { + _value = next; + _pos = _mapPercentToPos(next); + }); + widget.onChanged?.call(next); + _debouncerScale.value = next; + } + + @override + void dispose() { + _debouncerScale.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Smaller button size for mobile + const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32); + + final sliderControl = Slider( + value: _pos, + min: 0.0, + max: 1.0, + divisions: (_maxPercent - _minPercent).round(), + label: '$_value%', + onChanged: (v) { + final snapped = _snapNormalizedPos(v); + final next = _mapPosToPercent(snapped); + if (next != _value || snapped != _pos) { + setState(() { + _pos = snapped; + _value = next; + }); + widget.onChanged?.call(next); + _debouncerScale.value = next; + } + }, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${translate("Scale custom")}: $_value%', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + SizedBox(height: 8), + Row( + children: [ + IconButton( + iconSize: 20, + padding: EdgeInsets.all(4), + constraints: smallBtnConstraints, + icon: const Icon(Icons.remove), + tooltip: translate('Decrease'), + onPressed: () => _nudge(-1), + ), + Expanded(child: sliderControl), + IconButton( + iconSize: 20, + padding: EdgeInsets.all(4), + constraints: smallBtnConstraints, + icon: const Icon(Icons.add), + tooltip: translate('Increase'), + onPressed: () => _nudge(1), + ), + ], + ), + ], + ), + ); + } +}