diff --git a/.gitignore b/.gitignore index fa6b4fc..b10f2dc 100644 --- a/.gitignore +++ b/.gitignore @@ -225,4 +225,6 @@ artifacts/ !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock \ No newline at end of file +!/dev/ci/**/Gemfile.lock +*.g.dart +*.freezed.dart diff --git a/DEVELOPERS.md b/DEVELOPERS.md new file mode 100644 index 0000000..1a15f65 --- /dev/null +++ b/DEVELOPERS.md @@ -0,0 +1,120 @@ +# Developer Guide + +## Prerequisites + +This is a Flutter desktop application targeting Windows, macOS, and Linux. + +### Flutter SDK + +The [Flutter install guide](https://docs.flutter.dev/install/quick) covers full details. Two common approaches: + +1. **Manual install** -- Download the SDK from [Flutter Manual Install](https://docs.flutter.dev/install/manual) and add `flutter/bin` to your PATH. +2. **Via VS Code** -- Install the Flutter extension. It will prompt you to download the SDK and select a folder. You may need to manually add `flutter/bin` to your PATH afterwards. + +### ADB / scrcpy + +This app builds command lines for [scrcpy](https://github.com/Genymobile/scrcpy) but does not bundle it. Download the latest release from [scrcpy/releases](https://github.com/Genymobile/scrcpy/releases) and ensure it is on your PATH. scrcpy ships with Android's `adb`, so no additional tools are needed. + +## Build & Run + +### Code generation (required) + +The project uses [freezed](https://pub.dev/packages/freezed) + [json_serializable](https://pub.dev/packages/json_serializable) for immutable models and JSON serialization. Generated files (`*.freezed.dart`, `*.g.dart`) are **not** checked into source control, so you must run code generation before the first build and any time you modify model fields: + +``` +dart run build_runner build --delete-conflicting-outputs +``` + +Without this step the app will not compile. + +### Running +Running will also compile the code at the same time. + +``` +flutter run -d windows # or linux, macos +``` + +To produce a release build: + +``` +flutter build windows # or linux, macos +``` + +### VS Code workspace + +Open the workspace at the `ScrcpyGui/` directory (e.g. `X:\sources\Scrcpy-GUI\ScrcpyGui`). Pre-configured tasks are available via **Command Palette > Run Task** (e.g. "flutter - run windows"). If Flutter is not found, add `flutter/bin` to your PATH and restart VS Code. + +## Architecture Overview + +``` +lib/ + models/ + scrcpy_options.dart # option classes + OptionsBundle (freezed) + scrcpy_options.freezed.dart # generated -- do not edit + scrcpy_options.g.dart # generated -- do not edit + services/ + command_builder_service.dart # central ChangeNotifier, builds CLI command + options_state_service.dart # JSON persistence to disk + settings_service.dart # app settings (UI prefs, paths) + commands_service.dart # saved command management + device_manager_service.dart # ADB device detection + pages/home_panels/ + *_panel.dart # 11 UI panels (one per option category) + utils/ + app_paths.dart # centralized app data directory resolution +``` + +**Data flow:** Each panel reads its options via `context.select` (granular rebuilds) and writes via `context.read().updateXxxOptions(...)`. The service holds an immutable `OptionsBundle` and uses `copyWith` to produce new state on every change. A debounced timer (4 seconds) auto-saves the bundle to `%APPDATA%/ScrcpyGui/scrcpy_options_state.json`. + +## Adding a New Option + +1. **Define the field** in the appropriate `@freezed` class in `lib/models/scrcpy_options.dart`. For example, to add a camera option: + + ```dart + @freezed + class CameraOptions with _$CameraOptions { + const CameraOptions._(); + const factory CameraOptions({ + // ... existing fields ... + @Default('') String myNewOption, // <-- add here + }) = _CameraOptions; + // ... + } + ``` + + That single `@Default('') String myNewOption` line gives you the field declaration, constructor parameter with default, `copyWith` support, JSON serialization, and equality -- all generated automatically by freezed. + +2. **Add the CLI flag** in the `generateCommandPart()` method of the same class: + + ```dart + if (myNewOption.isNotEmpty) cmd += ' --my-new-option=$myNewOption'; + ``` + +3. **Re-run code generation:** + + ``` + dart run build_runner build --delete-conflicting-outputs + ``` + +4. **Add the UI widget** in the corresponding panel file under `lib/pages/home_panels/`. Follow the existing pattern: + + ```dart + CustomTextField( + label: 'My New Option', + value: opts.myNewOption, + onChanged: (val) { + cmdService.updateCameraOptions(opts.copyWith(myNewOption: val)); + debugPrint('[CameraPanel] Updated CameraOptions'); + }, + ), + ``` + +No changes are needed in the service layer, persistence, or serialization -- freezed and the `OptionsBundle` handle everything automatically. + +## State Persistence + +Options are persisted as JSON to `%APPDATA%/ScrcpyGui/scrcpy_options_state.json` (Windows) or the equivalent `getApplicationSupportDirectory()` path on other platforms. The centralized `AppPaths` utility resolves and caches this base directory. + +- **Auto-save:** A 4-second debounced timer writes after each change or when closing. + +- **Deserialization safety:** If the saved JSON is corrupt or has missing/renamed fields, the app falls back to defaults via try/catch. Freezed's `@Default` annotations handle missing fields gracefully. diff --git a/ScrcpyGui/lib/main.dart b/ScrcpyGui/lib/main.dart index 424d6e6..6400449 100644 --- a/ScrcpyGui/lib/main.dart +++ b/ScrcpyGui/lib/main.dart @@ -15,6 +15,7 @@ import 'models/settings_model.dart'; import 'pages/home_page.dart'; import 'services/command_builder_service.dart'; import 'services/device_manager_service.dart'; +import 'services/options_state_service.dart'; import 'services/settings_service.dart'; import 'theme/app_theme.dart'; import 'widgets/sidebar.dart'; @@ -46,6 +47,12 @@ Future main() async { final commandBuilder = CommandBuilderService(); commandBuilder.deviceManagerService = deviceManager; + // Restore persisted options state (survives app restarts) + final savedOptions = await OptionsStateService().loadOptionsState(); + if (savedOptions != null) { + commandBuilder.loadOptionsFromJson(savedOptions); + } + // Load settings final settingsService = SettingsService(); final settings = await settingsService.loadSettings(); @@ -84,7 +91,7 @@ class ScrcpyGuiApp extends StatefulWidget { State createState() => _ScrcpyGuiAppState(); } -class _ScrcpyGuiAppState extends State { +class _ScrcpyGuiAppState extends State with WindowListener { /// Currently selected page index (0: Home, 1: Favorites, 2: Resources, 3: Settings) late int selectedIndex; late AppSettings _currentSettings; @@ -96,9 +103,31 @@ class _ScrcpyGuiAppState extends State { _currentSettings = widget.settings; // Set initial tab based on bootTab setting selectedIndex = _getInitialTabIndex(); + windowManager.addListener(this); + windowManager.setPreventClose(true); _startSettingsPolling(); } + @override + void dispose() { + windowManager.removeListener(this); + windowManager.setPreventClose(false); + super.dispose(); + } + + @override + Future onWindowClose() async { + final isPreventClose = await windowManager.isPreventClose(); + if (!isPreventClose) { + return; + } + + final commandBuilder = context.read(); + await commandBuilder.flushPendingSave(); + await windowManager.setPreventClose(false); + await windowManager.close(); + } + int _getInitialTabIndex() { switch (_currentSettings.bootTab) { case 'Favorites': @@ -166,11 +195,6 @@ class _ScrcpyGuiAppState extends State { selectedIndex: selectedIndex, showBatFilesTab: _currentSettings.showBatFilesTab, onItemSelected: (index) { - // Clear command builder when leaving Home page (index 0) - if (selectedIndex == 0 && index != 0) { - final commandService = Provider.of(context, listen: false); - commandService.resetToDefaults(); - } setState(() => selectedIndex = index); }, ), diff --git a/ScrcpyGui/lib/models/scrcpy_options.dart b/ScrcpyGui/lib/models/scrcpy_options.dart index 48d61d4..c731f8b 100644 --- a/ScrcpyGui/lib/models/scrcpy_options.dart +++ b/ScrcpyGui/lib/models/scrcpy_options.dart @@ -1,44 +1,51 @@ import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'scrcpy_options.freezed.dart'; +part 'scrcpy_options.g.dart'; + +// --------------------------------------------------------------------------- +// OptionsBundle — wraps all 10 option objects for persistence +// --------------------------------------------------------------------------- + +@freezed +class OptionsBundle with _$OptionsBundle { + const factory OptionsBundle({ + @Default(AudioOptions()) AudioOptions audioOptions, + @Default(ScreenRecordingOptions()) ScreenRecordingOptions recordingOptions, + @Default(VirtualDisplayOptions()) VirtualDisplayOptions virtualDisplayOptions, + @Default(GeneralCastOptions()) GeneralCastOptions generalCastOptions, + @Default(CameraOptions()) CameraOptions cameraOptions, + @Default(InputControlOptions()) InputControlOptions inputControlOptions, + @Default(DisplayWindowOptions()) DisplayWindowOptions displayWindowOptions, + @Default(NetworkConnectionOptions()) NetworkConnectionOptions networkConnectionOptions, + @Default(AdvancedOptions()) AdvancedOptions advancedOptions, + @Default(OtgModeOptions()) OtgModeOptions otgModeOptions, + }) = _OptionsBundle; + + factory OptionsBundle.fromJson(Map json) => + _$OptionsBundleFromJson(json); +} -/// Screen Recording Options -class ScreenRecordingOptions { - String maxSize; - String bitrate; - String framerate; - String outputFormat; - String outputFile; - String recordOrientation; - String videoCodec; - - ScreenRecordingOptions({ - this.maxSize = '', - this.bitrate = '', - this.framerate = '', - this.outputFormat = '', - this.outputFile = '', - this.recordOrientation = '', - this.videoCodec = '', - }); - - ScreenRecordingOptions copyWith({ - String? maxSize, - String? bitrate, - String? framerate, - String? outputFormat, - String? outputFile, - String? recordOrientation, - String? videoCodec, - }) { - return ScreenRecordingOptions( - maxSize: maxSize ?? this.maxSize, - bitrate: bitrate ?? this.bitrate, - framerate: framerate ?? this.framerate, - outputFormat: outputFormat ?? this.outputFormat, - outputFile: outputFile ?? this.outputFile, - recordOrientation: recordOrientation ?? this.recordOrientation, - videoCodec: videoCodec ?? this.videoCodec, - ); - } +// --------------------------------------------------------------------------- +// Screen Recording Options +// --------------------------------------------------------------------------- + +@freezed +class ScreenRecordingOptions with _$ScreenRecordingOptions { + const ScreenRecordingOptions._(); + const factory ScreenRecordingOptions({ + @Default('') String maxSize, + @Default('') String bitrate, + @Default('') String framerate, + @Default('') String outputFormat, + @Default('') String outputFile, + @Default('') String recordOrientation, + @Default('') String videoCodec, + }) = _ScreenRecordingOptions; + + factory ScreenRecordingOptions.fromJson(Map json) => + _$ScreenRecordingOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -57,43 +64,25 @@ class ScreenRecordingOptions { debugPrint('[ScreenRecordingOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Virtual Display Options -class VirtualDisplayOptions { - bool newDisplay; - String resolution; - bool noVdDestroyContent; - bool noVdSystemDecorations; - String dpi; - - VirtualDisplayOptions({ - this.newDisplay = false, - this.resolution = '', - this.noVdDestroyContent = false, - this.noVdSystemDecorations = false, - this.dpi = '', - }); - - VirtualDisplayOptions copyWith({ - bool? newDisplay, - String? resolution, - bool? noVdDestroyContent, - bool? noVdSystemDecorations, - String? dpi, - }) { - return VirtualDisplayOptions( - newDisplay: newDisplay ?? this.newDisplay, - resolution: resolution ?? this.resolution, - noVdDestroyContent: noVdDestroyContent ?? this.noVdDestroyContent, - noVdSystemDecorations: - noVdSystemDecorations ?? this.noVdSystemDecorations, - dpi: dpi ?? this.dpi, - ); - } +// --------------------------------------------------------------------------- +// Virtual Display Options +// --------------------------------------------------------------------------- + +@freezed +class VirtualDisplayOptions with _$VirtualDisplayOptions { + const VirtualDisplayOptions._(); + const factory VirtualDisplayOptions({ + @Default(false) bool newDisplay, + @Default('') String resolution, + @Default(false) bool noVdDestroyContent, + @Default(false) bool noVdSystemDecorations, + @Default('') String dpi, + }) = _VirtualDisplayOptions; + + factory VirtualDisplayOptions.fromJson(Map json) => + _$VirtualDisplayOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -111,55 +100,28 @@ class VirtualDisplayOptions { debugPrint('[VirtualDisplayOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Audio Options -class AudioOptions { - String audioBitRate; - String audioBuffer; - bool audioDup; - bool noAudio; - String audioCodecOptions; - String audioCodecEncoderPair; - String audioCodec; - String audioSource; - - AudioOptions({ - this.audioBitRate = '', - this.audioBuffer = '', - this.audioDup = false, - this.noAudio = false, - this.audioCodecOptions = '', - this.audioCodecEncoderPair = '', - this.audioCodec = '', - this.audioSource = '', - }); - - AudioOptions copyWith({ - String? audioBitRate, - String? audioBuffer, - bool? audioDup, - bool? noAudio, - String? audioCodecOptions, - String? audioCodecEncoderPair, - String? audioCodec, - String? audioSource, - }) { - return AudioOptions( - audioBitRate: audioBitRate ?? this.audioBitRate, - audioBuffer: audioBuffer ?? this.audioBuffer, - audioDup: audioDup ?? this.audioDup, - noAudio: noAudio ?? this.noAudio, - audioCodecOptions: audioCodecOptions ?? this.audioCodecOptions, - audioCodecEncoderPair: - audioCodecEncoderPair ?? this.audioCodecEncoderPair, - audioCodec: audioCodec ?? this.audioCodec, - audioSource: audioSource ?? this.audioSource, - ); - } +// --------------------------------------------------------------------------- +// Audio Options +// --------------------------------------------------------------------------- + +@freezed +class AudioOptions with _$AudioOptions { + const AudioOptions._(); + const factory AudioOptions({ + @Default('') String audioBitRate, + @Default('') String audioBuffer, + @Default(false) bool audioDup, + @Default(false) bool noAudio, + @Default('') String audioCodecOptions, + @Default('') String audioCodecEncoderPair, + @Default('') String audioCodec, + @Default('') String audioSource, + }) = _AudioOptions; + + factory AudioOptions.fromJson(Map json) => + _$AudioOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -175,87 +137,36 @@ class AudioOptions { debugPrint('[AudioOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// General Cast / Display Options -class GeneralCastOptions { - bool fullscreen; - bool turnScreenOff; - String windowTitle; - String crop; - String extraParameters; - String videoOrientation; - String videoCodecEncoderPair; - bool stayAwake; - bool windowBorderless; - bool windowAlwaysOnTop; - bool disableScreensaver; - String videoBitRate; - String selectedPackage; - bool printFps; - String timeLimit; - bool powerOffOnClose; - - GeneralCastOptions({ - this.fullscreen = false, - this.turnScreenOff = false, - this.windowTitle = '', - this.crop = '', - this.extraParameters = '', - this.videoOrientation = '', - this.videoCodecEncoderPair = '', - this.stayAwake = false, - this.windowBorderless = false, - this.windowAlwaysOnTop = false, - this.disableScreensaver = false, - this.videoBitRate = '', - this.selectedPackage = '', - this.printFps = false, - this.timeLimit = '', - this.powerOffOnClose = false, - }); - - GeneralCastOptions copyWith({ - bool? fullscreen, - bool? turnScreenOff, - String? windowTitle, - String? crop, - String? extraParameters, - String? videoOrientation, - String? videoCodecEncoderPair, - bool? stayAwake, - bool? windowBorderless, - bool? windowAlwaysOnTop, - bool? disableScreensaver, - String? videoBitRate, - String? selectedPackage, - bool? printFps, - String? timeLimit, - bool? powerOffOnClose, - }) { - return GeneralCastOptions( - fullscreen: fullscreen ?? this.fullscreen, - turnScreenOff: turnScreenOff ?? this.turnScreenOff, - windowTitle: windowTitle ?? this.windowTitle, - crop: crop ?? this.crop, - extraParameters: extraParameters ?? this.extraParameters, - videoOrientation: videoOrientation ?? this.videoOrientation, - videoCodecEncoderPair: - videoCodecEncoderPair ?? this.videoCodecEncoderPair, - stayAwake: stayAwake ?? this.stayAwake, - windowBorderless: windowBorderless ?? this.windowBorderless, - windowAlwaysOnTop: windowAlwaysOnTop ?? this.windowAlwaysOnTop, - disableScreensaver: disableScreensaver ?? this.disableScreensaver, - videoBitRate: videoBitRate ?? this.videoBitRate, - selectedPackage: selectedPackage ?? this.selectedPackage, - printFps: printFps ?? this.printFps, - timeLimit: timeLimit ?? this.timeLimit, - powerOffOnClose: powerOffOnClose ?? this.powerOffOnClose, - ); - } +// --------------------------------------------------------------------------- +// General Cast / Display Options +// --------------------------------------------------------------------------- + +@freezed +class GeneralCastOptions with _$GeneralCastOptions { + const GeneralCastOptions._(); + const factory GeneralCastOptions({ + @Default(false) bool fullscreen, + @Default(false) bool turnScreenOff, + @Default('') String windowTitle, + @Default('') String crop, + @Default('') String extraParameters, + @Default('') String videoOrientation, + @Default('') String videoCodecEncoderPair, + @Default(false) bool stayAwake, + @Default(false) bool windowBorderless, + @Default(false) bool windowAlwaysOnTop, + @Default(false) bool disableScreensaver, + @Default('') String videoBitRate, + @Default('') String selectedPackage, + @Default(false) bool printFps, + @Default('') String timeLimit, + @Default(false) bool powerOffOnClose, + }) = _GeneralCastOptions; + + factory GeneralCastOptions.fromJson(Map json) => + _$GeneralCastOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -281,46 +192,26 @@ class GeneralCastOptions { debugPrint('[GeneralCastOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Camera Options -class CameraOptions { - String cameraId; - String cameraSize; - String cameraFacing; - String cameraFps; - String cameraAr; - bool cameraHighSpeed; - - CameraOptions({ - this.cameraId = '', - this.cameraSize = '', - this.cameraFacing = '', - this.cameraFps = '', - this.cameraAr = '', - this.cameraHighSpeed = false, - }); - - CameraOptions copyWith({ - String? cameraId, - String? cameraSize, - String? cameraFacing, - String? cameraFps, - String? cameraAr, - bool? cameraHighSpeed, - }) { - return CameraOptions( - cameraId: cameraId ?? this.cameraId, - cameraSize: cameraSize ?? this.cameraSize, - cameraFacing: cameraFacing ?? this.cameraFacing, - cameraFps: cameraFps ?? this.cameraFps, - cameraAr: cameraAr ?? this.cameraAr, - cameraHighSpeed: cameraHighSpeed ?? this.cameraHighSpeed, - ); - } +// --------------------------------------------------------------------------- +// Camera Options +// --------------------------------------------------------------------------- + +@freezed +class CameraOptions with _$CameraOptions { + const CameraOptions._(); + const factory CameraOptions({ + @Default('') String cameraId, + @Default('') String cameraSize, + @Default('') String cameraFacing, + @Default('') String cameraFps, + @Default('') String cameraAr, + @Default(false) bool cameraHighSpeed, + }) = _CameraOptions; + + factory CameraOptions.fromJson(Map json) => + _$CameraOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -333,62 +224,30 @@ class CameraOptions { debugPrint('[CameraOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Input Control Options -class InputControlOptions { - bool noControl; - bool noMouseHover; - bool forwardAllClicks; - bool legacyPaste; - bool noKeyRepeat; - bool rawKeyEvents; - bool preferText; - String mouseBind; - String keyboardMode; - String mouseMode; - - InputControlOptions({ - this.noControl = false, - this.noMouseHover = false, - this.forwardAllClicks = false, - this.legacyPaste = false, - this.noKeyRepeat = false, - this.rawKeyEvents = false, - this.preferText = false, - this.mouseBind = '', - this.keyboardMode = '', - this.mouseMode = '', - }); - - InputControlOptions copyWith({ - bool? noControl, - bool? noMouseHover, - bool? forwardAllClicks, - bool? legacyPaste, - bool? noKeyRepeat, - bool? rawKeyEvents, - bool? preferText, - String? mouseBind, - String? keyboardMode, - String? mouseMode, - }) { - return InputControlOptions( - noControl: noControl ?? this.noControl, - noMouseHover: noMouseHover ?? this.noMouseHover, - forwardAllClicks: forwardAllClicks ?? this.forwardAllClicks, - legacyPaste: legacyPaste ?? this.legacyPaste, - noKeyRepeat: noKeyRepeat ?? this.noKeyRepeat, - rawKeyEvents: rawKeyEvents ?? this.rawKeyEvents, - preferText: preferText ?? this.preferText, - mouseBind: mouseBind ?? this.mouseBind, - keyboardMode: keyboardMode ?? this.keyboardMode, - mouseMode: mouseMode ?? this.mouseMode, - ); - } +// --------------------------------------------------------------------------- +// Input Control Options +// --------------------------------------------------------------------------- + +@freezed +class InputControlOptions with _$InputControlOptions { + const InputControlOptions._(); + const factory InputControlOptions({ + @Default(false) bool noControl, + @Default(false) bool noMouseHover, + @Default(false) bool forwardAllClicks, + @Default(false) bool legacyPaste, + @Default(false) bool noKeyRepeat, + @Default(false) bool rawKeyEvents, + @Default(false) bool preferText, + @Default('') String mouseBind, + @Default('') String keyboardMode, + @Default('') String mouseMode, + }) = _InputControlOptions; + + factory InputControlOptions.fromJson(Map json) => + _$InputControlOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -405,58 +264,29 @@ class InputControlOptions { debugPrint('[InputControlOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Display/Window Configuration Options -class DisplayWindowOptions { - String windowX; - String windowY; - String windowWidth; - String windowHeight; - String rotation; - String displayId; - String displayBuffer; - String renderDriver; - bool forceAdbForward; - - DisplayWindowOptions({ - this.windowX = '', - this.windowY = '', - this.windowWidth = '', - this.windowHeight = '', - this.rotation = '', - this.displayId = '', - this.displayBuffer = '', - this.renderDriver = '', - this.forceAdbForward = false, - }); - - DisplayWindowOptions copyWith({ - String? windowX, - String? windowY, - String? windowWidth, - String? windowHeight, - String? rotation, - String? displayId, - String? displayBuffer, - String? renderDriver, - bool? forceAdbForward, - }) { - return DisplayWindowOptions( - windowX: windowX ?? this.windowX, - windowY: windowY ?? this.windowY, - windowWidth: windowWidth ?? this.windowWidth, - windowHeight: windowHeight ?? this.windowHeight, - rotation: rotation ?? this.rotation, - displayId: displayId ?? this.displayId, - displayBuffer: displayBuffer ?? this.displayBuffer, - renderDriver: renderDriver ?? this.renderDriver, - forceAdbForward: forceAdbForward ?? this.forceAdbForward, - ); - } +// --------------------------------------------------------------------------- +// Display/Window Configuration Options +// --------------------------------------------------------------------------- + +@freezed +class DisplayWindowOptions with _$DisplayWindowOptions { + const DisplayWindowOptions._(); + const factory DisplayWindowOptions({ + @Default('') String windowX, + @Default('') String windowY, + @Default('') String windowWidth, + @Default('') String windowHeight, + @Default('') String rotation, + @Default('') String displayId, + @Default('') String displayBuffer, + @Default('') String renderDriver, + @Default(false) bool forceAdbForward, + }) = _DisplayWindowOptions; + + factory DisplayWindowOptions.fromJson(Map json) => + _$DisplayWindowOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -472,42 +302,25 @@ class DisplayWindowOptions { debugPrint('[DisplayWindowOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Network/Connection Options -class NetworkConnectionOptions { - String tcpipPort; - bool selectTcpip; - String tunnelHost; - String tunnelPort; - bool noAdbForward; - - NetworkConnectionOptions({ - this.tcpipPort = '', - this.selectTcpip = false, - this.tunnelHost = '', - this.tunnelPort = '', - this.noAdbForward = false, - }); - - NetworkConnectionOptions copyWith({ - String? tcpipPort, - bool? selectTcpip, - String? tunnelHost, - String? tunnelPort, - bool? noAdbForward, - }) { - return NetworkConnectionOptions( - tcpipPort: tcpipPort ?? this.tcpipPort, - selectTcpip: selectTcpip ?? this.selectTcpip, - tunnelHost: tunnelHost ?? this.tunnelHost, - tunnelPort: tunnelPort ?? this.tunnelPort, - noAdbForward: noAdbForward ?? this.noAdbForward, - ); - } +// --------------------------------------------------------------------------- +// Network/Connection Options +// --------------------------------------------------------------------------- + +@freezed +class NetworkConnectionOptions with _$NetworkConnectionOptions { + const NetworkConnectionOptions._(); + const factory NetworkConnectionOptions({ + @Default('') String tcpipPort, + @Default(false) bool selectTcpip, + @Default('') String tunnelHost, + @Default('') String tunnelPort, + @Default(false) bool noAdbForward, + }) = _NetworkConnectionOptions; + + factory NetworkConnectionOptions.fromJson(Map json) => + _$NetworkConnectionOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -519,42 +332,25 @@ class NetworkConnectionOptions { debugPrint('[NetworkConnectionOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// Advanced/Developer Options -class AdvancedOptions { - String verbosity; - bool noCleanup; - bool noDownsizeOnError; - String v4l2Sink; - String v4l2Buffer; - - AdvancedOptions({ - this.verbosity = '', - this.noCleanup = false, - this.noDownsizeOnError = false, - this.v4l2Sink = '', - this.v4l2Buffer = '', - }); - - AdvancedOptions copyWith({ - String? verbosity, - bool? noCleanup, - bool? noDownsizeOnError, - String? v4l2Sink, - String? v4l2Buffer, - }) { - return AdvancedOptions( - verbosity: verbosity ?? this.verbosity, - noCleanup: noCleanup ?? this.noCleanup, - noDownsizeOnError: noDownsizeOnError ?? this.noDownsizeOnError, - v4l2Sink: v4l2Sink ?? this.v4l2Sink, - v4l2Buffer: v4l2Buffer ?? this.v4l2Buffer, - ); - } +// --------------------------------------------------------------------------- +// Advanced/Developer Options +// --------------------------------------------------------------------------- + +@freezed +class AdvancedOptions with _$AdvancedOptions { + const AdvancedOptions._(); + const factory AdvancedOptions({ + @Default('') String verbosity, + @Default(false) bool noCleanup, + @Default(false) bool noDownsizeOnError, + @Default('') String v4l2Sink, + @Default('') String v4l2Buffer, + }) = _AdvancedOptions; + + factory AdvancedOptions.fromJson(Map json) => + _$AdvancedOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -566,34 +362,23 @@ class AdvancedOptions { debugPrint('[AdvancedOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } -/// OTG Mode Options -class OtgModeOptions { - bool otg; - bool hidKeyboard; - bool hidMouse; - - OtgModeOptions({ - this.otg = false, - this.hidKeyboard = false, - this.hidMouse = false, - }); - - OtgModeOptions copyWith({ - bool? otg, - bool? hidKeyboard, - bool? hidMouse, - }) { - return OtgModeOptions( - otg: otg ?? this.otg, - hidKeyboard: hidKeyboard ?? this.hidKeyboard, - hidMouse: hidMouse ?? this.hidMouse, - ); - } +// --------------------------------------------------------------------------- +// OTG Mode Options +// --------------------------------------------------------------------------- + +@freezed +class OtgModeOptions with _$OtgModeOptions { + const OtgModeOptions._(); + const factory OtgModeOptions({ + @Default(false) bool otg, + @Default(false) bool hidKeyboard, + @Default(false) bool hidMouse, + }) = _OtgModeOptions; + + factory OtgModeOptions.fromJson(Map json) => + _$OtgModeOptionsFromJson(json); String generateCommandPart() { var cmd = ''; @@ -603,7 +388,4 @@ class OtgModeOptions { debugPrint('[OtgModeOptions] => $cmd'); return cmd.trim(); } - - @override - String toString() => generateCommandPart(); } diff --git a/ScrcpyGui/lib/pages/home_panels/advanced_panel.dart b/ScrcpyGui/lib/pages/home_panels/advanced_panel.dart index db2e448..81e64a2 100644 --- a/ScrcpyGui/lib/pages/home_panels/advanced_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/advanced_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; import '../../widgets/custom_searchbar.dart'; @@ -36,12 +37,6 @@ class AdvancedPanel extends StatefulWidget { } class _AdvancedPanelState extends State { - String? verbosity; - bool noCleanup = false; - bool noDownsizeOnError = false; - String v4l2Sink = ''; - String v4l2Buffer = ''; - final List verbosityOptions = [ 'verbose', 'debug', @@ -50,46 +45,22 @@ class _AdvancedPanelState extends State { 'error', ]; - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.advancedOptions.copyWith( - verbosity: verbosity ?? '', - noCleanup: noCleanup, - noDownsizeOnError: noDownsizeOnError, - v4l2Sink: v4l2Sink, - v4l2Buffer: v4l2Buffer, - ); - - cmdService.updateAdvancedOptions(options); - - debugPrint( - '[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - verbosity = null; - noCleanup = false; - noDownsizeOnError = false; - v4l2Sink = ''; - v4l2Buffer = ''; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.advancedOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.settings_applications, title: 'Advanced/Developer', showButton: true, panelType: "Advanced", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateAdvancedOptions(const AdvancedOptions()); + debugPrint('[AdvancedPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -100,15 +71,15 @@ class _AdvancedPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Verbosity Level', - value: verbosity, + value: opts.verbosity.isNotEmpty ? opts.verbosity : null, suggestions: verbosityOptions, onChanged: (val) { - setState(() => verbosity = val); - _updateService(context); + cmdService.updateAdvancedOptions(opts.copyWith(verbosity: val)); + debugPrint('[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => verbosity = null); - _updateService(context); + cmdService.updateAdvancedOptions(opts.copyWith(verbosity: '')); + debugPrint('[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the log level (verbose, debug, info, warn or error). Default is info.', ), @@ -117,10 +88,10 @@ class _AdvancedPanelState extends State { Expanded( child: CustomCheckbox( label: 'No Cleanup', - value: noCleanup, + value: opts.noCleanup, onChanged: (val) { - setState(() => noCleanup = val); - _updateService(context); + cmdService.updateAdvancedOptions(opts.copyWith(noCleanup: val)); + debugPrint('[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}'); }, tooltip: 'By default, scrcpy removes the server binary from the device and restores the device state on exit. This option disables this cleanup.', ), @@ -129,10 +100,10 @@ class _AdvancedPanelState extends State { Expanded( child: CustomCheckbox( label: 'No Downsize on Error', - value: noDownsizeOnError, + value: opts.noDownsizeOnError, onChanged: (val) { - setState(() => noDownsizeOnError = val); - _updateService(context); + cmdService.updateAdvancedOptions(opts.copyWith(noDownsizeOnError: val)); + debugPrint('[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}'); }, tooltip: 'By default, on MediaCodec error, scrcpy automatically tries again with a lower definition. This option disables this behavior.', ), @@ -145,10 +116,10 @@ class _AdvancedPanelState extends State { Expanded( child: CustomTextField( label: 'V4L2 Sink (Linux)', - value: v4l2Sink, + value: opts.v4l2Sink, onChanged: (val) { - setState(() => v4l2Sink = val); - _updateService(context); + cmdService.updateAdvancedOptions(opts.copyWith(v4l2Sink: val)); + debugPrint('[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}'); }, tooltip: 'Output to v4l2loopback device (e.g., /dev/videoN). This feature is only available on Linux.', ), @@ -157,10 +128,10 @@ class _AdvancedPanelState extends State { Expanded( child: CustomTextField( label: 'V4L2 Buffer (ms)', - value: v4l2Buffer, + value: opts.v4l2Buffer, onChanged: (val) { - setState(() => v4l2Buffer = val); - _updateService(context); + cmdService.updateAdvancedOptions(opts.copyWith(v4l2Buffer: val)); + debugPrint('[AdvancedPanel] Updated AdvancedOptions → ${cmdService.fullCommand}'); }, tooltip: 'Add a buffering delay (in milliseconds) before pushing frames. This increases latency to compensate for jitter. Default is 0 (no buffering). Linux only.', ), diff --git a/ScrcpyGui/lib/pages/home_panels/audio_commands_panel.dart b/ScrcpyGui/lib/pages/home_panels/audio_commands_panel.dart index 945f5b9..395129a 100644 --- a/ScrcpyGui/lib/pages/home_panels/audio_commands_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/audio_commands_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../services/device_manager_service.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; @@ -37,14 +38,6 @@ class AudioCommandsPanel extends StatefulWidget { } class _AudioCommandsPanelState extends State { - String audioBitRate = ''; - String audioBuffer = ''; - String audioCodecOptions = ''; - String audioSource = ''; - String audioCodec = ''; - bool noAudio = false; - bool audioDuplication = false; - final List audioBitRateOptions = [ '64k', '128k', @@ -73,6 +66,7 @@ class _AudioCommandsPanelState extends State { _loadAudioCodecs(); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; _deviceManager = Provider.of( context, listen: false, @@ -94,22 +88,13 @@ class _AudioCommandsPanelState extends State { final selectedDevice = deviceManager.selectedDevice; if (selectedDevice == null) { - setState(() { - audioCodecEncoders = []; - audioCodec = ''; - }); + if (mounted) setState(() => audioCodecEncoders = []); return; } final info = DeviceManagerService.devicesInfo[selectedDevice]; if (info != null) { - setState(() { - audioCodecEncoders = info.audioCodecs; - - if (!audioCodecEncoders.contains(audioCodec)) { - audioCodec = ''; - } - }); + if (mounted) setState(() => audioCodecEncoders = info.audioCodecs); } } @@ -119,52 +104,23 @@ class _AudioCommandsPanelState extends State { super.dispose(); } - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.audioOptions.copyWith( - audioBitRate: audioBitRate, - audioBuffer: audioBuffer, - audioCodecOptions: audioCodecOptions, - audioSource: audioSource, - audioCodecEncoderPair: audioCodec, - audioDup: audioDuplication, - noAudio: noAudio, - ); - - cmdService.updateAudioOptions(options); - - debugPrint( - '[AudioPanel] Updated AudioOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - audioBitRate = ''; - audioBuffer = ''; - audioCodecOptions = ''; - audioSource = ''; - audioCodec = ''; - noAudio = false; - audioDuplication = false; - }); - _updateService(context); - debugPrint('[AudioPanel] Fields cleared!'); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.audioOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.headphones, title: 'Audio', showButton: true, panelType: "Audio", clearController: widget.clearController, - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateAudioOptions(const AudioOptions()); + debugPrint('[AudioCommandsPanel] Fields cleared!'); + }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -174,15 +130,15 @@ class _AudioCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Audio Bit Rate', - value: audioBitRate.isNotEmpty ? audioBitRate : null, + value: opts.audioBitRate.isNotEmpty ? opts.audioBitRate : null, suggestions: audioBitRateOptions, onChanged: (val) { - setState(() => audioBitRate = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioBitRate: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => audioBitRate = ''); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioBitRate: '')); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, tooltip: 'Encode the audio at the given bit rate, expressed in bits/s. Unit suffixes are supported: \'K\' (x1000) and \'M\' (x1000000). Default is 128K (128000).', ), @@ -191,15 +147,15 @@ class _AudioCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Audio Buffer', - value: audioBuffer.isNotEmpty ? audioBuffer : null, + value: opts.audioBuffer.isNotEmpty ? opts.audioBuffer : null, suggestions: audioBufferOptions, onChanged: (val) { - setState(() => audioBuffer = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioBuffer: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => audioBuffer = ''); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioBuffer: '')); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, tooltip: 'Configure the audio buffering delay (in milliseconds). Lower values decrease the latency, but increase the likelihood of buffer underrun (causing audio glitches). Default is 50.', ), @@ -208,17 +164,17 @@ class _AudioCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Audio Codec Options', - value: audioCodecOptions.isNotEmpty - ? audioCodecOptions + value: opts.audioCodecOptions.isNotEmpty + ? opts.audioCodecOptions : null, suggestions: audioCodecOptionsList, onChanged: (val) { - setState(() => audioCodecOptions = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioCodecOptions: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => audioCodecOptions = ''); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioCodecOptions: '')); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set codec-specific options for the device audio encoder. The list of possible codec options is available in the Android documentation.', ), @@ -231,15 +187,15 @@ class _AudioCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Audio Source', - value: audioSource.isNotEmpty ? audioSource : null, + value: opts.audioSource.isNotEmpty ? opts.audioSource : null, suggestions: audioSources, onChanged: (val) { - setState(() => audioSource = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioSource: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => audioSource = ''); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioSource: '')); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, tooltip: 'Select the audio source: output (whole audio output), playback (audio playback), mic (microphone), mic-unprocessed, mic-camcorder, mic-voice-recognition, mic-voice-communication. Default is output.', ), @@ -248,10 +204,10 @@ class _AudioCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'No Audio', - value: noAudio, + value: opts.noAudio, onChanged: (val) { - setState(() => noAudio = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(noAudio: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, tooltip: 'Disable audio forwarding.', ), @@ -260,10 +216,10 @@ class _AudioCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Audio Duplication', - value: audioDuplication, + value: opts.audioDup, onChanged: (val) { - setState(() => audioDuplication = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioDup: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, tooltip: 'Duplicate audio (capture and keep playing on the device). This feature is only available with --audio-source=playback.', ), @@ -273,15 +229,17 @@ class _AudioCommandsPanelState extends State { const SizedBox(height: 16), CustomSearchBar( hintText: 'Audio Codec - Encoder', - value: audioCodec.isNotEmpty ? audioCodec : null, + value: opts.audioCodecEncoderPair.isNotEmpty + ? opts.audioCodecEncoderPair + : null, suggestions: audioCodecEncoders, onChanged: (val) { - setState(() => audioCodec = val); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioCodecEncoderPair: val)); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => audioCodec = ''); - _updateService(context); + cmdService.updateAudioOptions(opts.copyWith(audioCodecEncoderPair: '')); + debugPrint('[AudioCommandsPanel] Updated AudioOptions → ${cmdService.fullCommand}'); }, onReload: _loadAudioCodecs, tooltip: 'Select an audio codec (opus, aac, flac or raw). Default is opus. Use a specific MediaCodec audio encoder (depending on the codec).', diff --git a/ScrcpyGui/lib/pages/home_panels/camera_commands_panel.dart b/ScrcpyGui/lib/pages/home_panels/camera_commands_panel.dart index bff3daf..1f73c3c 100644 --- a/ScrcpyGui/lib/pages/home_panels/camera_commands_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/camera_commands_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; import '../../widgets/custom_searchbar.dart'; @@ -37,13 +38,6 @@ class CameraCommandsPanel extends StatefulWidget { } class _CameraCommandsPanelState extends State { - String cameraId = ''; - String cameraSize = ''; - String? cameraFacing; - String cameraFps = ''; - String cameraAr = ''; - bool cameraHighSpeed = false; - final List cameraFacingOptions = ['front', 'back', 'external']; final List cameraSizeOptions = [ '1920x1080', @@ -54,48 +48,22 @@ class _CameraCommandsPanelState extends State { final List cameraFpsOptions = ['15', '30', '60']; final List cameraArOptions = ['16:9', '4:3', '1:1']; - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.cameraOptions.copyWith( - cameraId: cameraId, - cameraSize: cameraSize, - cameraFacing: cameraFacing ?? '', - cameraFps: cameraFps, - cameraAr: cameraAr, - cameraHighSpeed: cameraHighSpeed, - ); - - cmdService.updateCameraOptions(options); - - debugPrint( - '[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - cameraId = ''; - cameraSize = ''; - cameraFacing = null; - cameraFps = ''; - cameraAr = ''; - cameraHighSpeed = false; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.cameraOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.camera_alt, title: 'Camera', showButton: true, panelType: "Camera", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateCameraOptions(const CameraOptions()); + debugPrint('[CameraPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -106,10 +74,10 @@ class _CameraCommandsPanelState extends State { Expanded( child: CustomTextField( label: 'Camera ID', - value: cameraId, + value: opts.cameraId, onChanged: (val) { - setState(() => cameraId = val); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraId: val)); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, tooltip: 'Specify the device camera id to mirror. The available camera ids can be listed by: scrcpy --list-cameras', ), @@ -118,15 +86,15 @@ class _CameraCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Camera Size', - value: cameraSize.isNotEmpty ? cameraSize : null, + value: opts.cameraSize.isNotEmpty ? opts.cameraSize : null, suggestions: cameraSizeOptions, onChanged: (val) { - setState(() => cameraSize = val); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraSize: val)); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => cameraSize = ''); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraSize: '')); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, tooltip: 'Specify an explicit camera capture size (e.g., 1920x1080).', ), @@ -135,15 +103,15 @@ class _CameraCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Camera Facing', - value: cameraFacing, + value: opts.cameraFacing.isNotEmpty ? opts.cameraFacing : null, suggestions: cameraFacingOptions, onChanged: (val) { - setState(() => cameraFacing = val); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraFacing: val)); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => cameraFacing = null); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraFacing: '')); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, tooltip: 'Select the device camera by its facing direction. Possible values are "front", "back" and "external".', ), @@ -156,15 +124,15 @@ class _CameraCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Camera FPS', - value: cameraFps.isNotEmpty ? cameraFps : null, + value: opts.cameraFps.isNotEmpty ? opts.cameraFps : null, suggestions: cameraFpsOptions, onChanged: (val) { - setState(() => cameraFps = val); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraFps: val)); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => cameraFps = ''); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraFps: '')); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, tooltip: 'Specify the camera capture frame rate. If not specified, Android\'s default frame rate (30 fps) is used.', ), @@ -173,15 +141,15 @@ class _CameraCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Camera Aspect Ratio', - value: cameraAr.isNotEmpty ? cameraAr : null, + value: opts.cameraAr.isNotEmpty ? opts.cameraAr : null, suggestions: cameraArOptions, onChanged: (val) { - setState(() => cameraAr = val); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraAr: val)); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => cameraAr = ''); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraAr: '')); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, tooltip: 'Select the camera size by its aspect ratio (+/- 10%). Possible values are "sensor" (use the camera sensor aspect ratio), ":" (e.g. "4:3") or "" (e.g. "1.6").', ), @@ -190,10 +158,10 @@ class _CameraCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'High Speed Mode', - value: cameraHighSpeed, + value: opts.cameraHighSpeed, onChanged: (val) { - setState(() => cameraHighSpeed = val); - _updateService(context); + cmdService.updateCameraOptions(opts.copyWith(cameraHighSpeed: val)); + debugPrint('[CameraPanel] Updated CameraOptions → ${cmdService.fullCommand}'); }, tooltip: 'Enable high-speed camera capture mode. This mode is restricted to specific resolutions and frame rates, listed by --list-camera-sizes.', ), diff --git a/ScrcpyGui/lib/pages/home_panels/common_commands_panel.dart b/ScrcpyGui/lib/pages/home_panels/common_commands_panel.dart index 4f9ec7f..0199e9b 100644 --- a/ScrcpyGui/lib/pages/home_panels/common_commands_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/common_commands_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../services/device_manager_service.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; @@ -38,22 +39,6 @@ class CommonCommandsPanel extends StatefulWidget { } class _CommonCommandsPanelState extends State { - String windowTitle = ''; - bool fullscreen = false; - bool screenOff = false; - bool stayAwake = false; - String cropScreen = ''; - String? videoOrientation; - bool windowBorderless = false; - bool windowAlwaysOnTop = false; - bool disableScreensaver = false; - String videoBitRate = ''; - String videoCodec = ''; - String extraParameters = ''; - bool printFps = false; - String timeLimit = ''; - bool powerOffOnClose = false; - List videoCodecOptions = []; final List orientationOptions = ['0', '90', '180', '270']; @@ -64,6 +49,7 @@ class _CommonCommandsPanelState extends State { super.initState(); _loadVideoCodecs(); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; _deviceManager = Provider.of( context, listen: false, @@ -72,7 +58,9 @@ class _CommonCommandsPanelState extends State { }); } - void _onDeviceChanged() => _loadVideoCodecs(); + void _onDeviceChanged() { + _loadVideoCodecs(); + } Future _loadVideoCodecs() async { final deviceManager = Provider.of( @@ -82,81 +70,18 @@ class _CommonCommandsPanelState extends State { final deviceId = deviceManager.selectedDevice; if (deviceId == null) { - setState(() { - videoCodecOptions = []; - videoCodec = ''; - }); + if (mounted) setState(() => videoCodecOptions = []); return; } final info = DeviceManagerService.devicesInfo[deviceId]; if (info != null) { - setState(() { - videoCodecOptions = info.videoCodecs; - if (!videoCodecOptions.contains(videoCodec)) { - videoCodec = ''; - } - }); + if (mounted) setState(() => videoCodecOptions = info.videoCodecs); } else { - setState(() { - videoCodecOptions = []; - videoCodec = ''; - }); + if (mounted) setState(() => videoCodecOptions = []); } } - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.generalCastOptions.copyWith( - fullscreen: fullscreen, - turnScreenOff: screenOff, - stayAwake: stayAwake, - windowTitle: windowTitle, - crop: cropScreen, - videoOrientation: videoOrientation ?? '', - windowBorderless: windowBorderless, - windowAlwaysOnTop: windowAlwaysOnTop, - disableScreensaver: disableScreensaver, - videoBitRate: videoBitRate, - videoCodecEncoderPair: videoCodec, - extraParameters: extraParameters, - printFps: printFps, - timeLimit: timeLimit, - powerOffOnClose: powerOffOnClose, - ); - - cmdService.updateGeneralCastOptions(options); - - debugPrint( - '[CommonPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - windowTitle = ''; - fullscreen = false; - screenOff = false; - stayAwake = false; - cropScreen = ''; - videoOrientation = null; - windowBorderless = false; - windowAlwaysOnTop = false; - disableScreensaver = false; - videoBitRate = ''; - videoCodec = ''; - extraParameters = ''; - printFps = false; - timeLimit = ''; - powerOffOnClose = false; - }); - _updateService(context); - } - @override void dispose() { _deviceManager?.selectedDeviceNotifier.removeListener(_onDeviceChanged); @@ -166,13 +91,20 @@ class _CommonCommandsPanelState extends State { @override Widget build(BuildContext context) { final hasDevices = videoCodecOptions.isNotEmpty; + final opts = context.select( + (s) => s.generalCastOptions, + ); + final cmdService = context.read(); return SurroundingPanel( icon: Icons.desktop_windows, title: 'General', showButton: true, panelType: "General", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateGeneralCastOptions(const GeneralCastOptions()); + debugPrint('[CommonCommandsPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -183,10 +115,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomTextField( label: 'Window Title', - value: windowTitle, + value: opts.windowTitle, onChanged: (val) { - setState(() => windowTitle = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(windowTitle: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set a custom window title.', ), @@ -195,10 +127,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Fullscreen', - value: fullscreen, + value: opts.fullscreen, onChanged: (val) { - setState(() => fullscreen = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(fullscreen: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Start in fullscreen.', ), @@ -207,10 +139,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Screen off', - value: screenOff, + value: opts.turnScreenOff, onChanged: (val) { - setState(() => screenOff = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(turnScreenOff: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Turn the device screen off immediately.', ), @@ -223,10 +155,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Stay Awake', - value: stayAwake, + value: opts.stayAwake, onChanged: (val) { - setState(() => stayAwake = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(stayAwake: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Keep the device on while scrcpy is running, when the device is plugged in.', ), @@ -235,10 +167,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomTextField( label: 'Crop Screen (W:H:X:Y)', - value: cropScreen, + value: opts.crop, onChanged: (val) { - setState(() => cropScreen = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(crop: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Crop the device screen on the server. The values are expressed in the device natural orientation (typically, portrait for a phone, landscape for a tablet).', ), @@ -247,15 +179,15 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomSearchBar( hintText: "Orientation", - value: videoOrientation, + value: opts.videoOrientation.isNotEmpty ? opts.videoOrientation : null, suggestions: orientationOptions, onChanged: (val) { - setState(() => videoOrientation = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(videoOrientation: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => videoOrientation = ''); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(videoOrientation: '')); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the initial display orientation. The number represents the clockwise rotation in degrees. Default is 0.', ), @@ -268,10 +200,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Window Borderless', - value: windowBorderless, + value: opts.windowBorderless, onChanged: (val) { - setState(() => windowBorderless = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(windowBorderless: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Disable window decorations (display borderless window).', ), @@ -280,10 +212,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Window Always on Top', - value: windowAlwaysOnTop, + value: opts.windowAlwaysOnTop, onChanged: (val) { - setState(() => windowAlwaysOnTop = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(windowAlwaysOnTop: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Make scrcpy window always on top (above other windows).', ), @@ -292,10 +224,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Disable Screensaver', - value: disableScreensaver, + value: opts.disableScreensaver, onChanged: (val) { - setState(() => disableScreensaver = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(disableScreensaver: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Disable screensaver while scrcpy is running.', ), @@ -308,10 +240,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomTextField( label: 'Video Bit Rate', - value: videoBitRate, + value: opts.videoBitRate, onChanged: (val) { - setState(() => videoBitRate = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(videoBitRate: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Encode the video at the given bit rate, expressed in bits/s. Unit suffixes are supported: \'K\' (x1000) and \'M\' (x1000000). Default is 8M (8000000).', ), @@ -327,15 +259,15 @@ class _CommonCommandsPanelState extends State { hintText: hasDevices ? "Search Codec..." : "No device connected", - value: videoCodec.isNotEmpty ? videoCodec : null, + value: opts.videoCodecEncoderPair.isNotEmpty ? opts.videoCodecEncoderPair : null, suggestions: videoCodecOptions, onChanged: (val) { - setState(() => videoCodec = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(videoCodecEncoderPair: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => videoCodec = ''); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(videoCodecEncoderPair: '')); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, onReload: _loadVideoCodecs, tooltip: 'Select a video codec (h264, h265 or av1). Default is h264. The available encoders can be listed from the device.', @@ -351,10 +283,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Print FPS', - value: printFps, + value: opts.printFps, onChanged: (val) { - setState(() => printFps = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(printFps: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Start FPS counter, to print framerate logs to the console. It can be started or stopped at any time with MOD+i.', ), @@ -363,10 +295,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomCheckbox( label: 'Power Off on Close', - value: powerOffOnClose, + value: opts.powerOffOnClose, onChanged: (val) { - setState(() => powerOffOnClose = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(powerOffOnClose: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Turn the device screen off when closing scrcpy.', ), @@ -375,10 +307,10 @@ class _CommonCommandsPanelState extends State { Expanded( child: CustomTextField( label: 'Time Limit (seconds)', - value: timeLimit, + value: opts.timeLimit, onChanged: (val) { - setState(() => timeLimit = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(timeLimit: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the maximum mirroring time, in seconds.', ), @@ -388,10 +320,10 @@ class _CommonCommandsPanelState extends State { const SizedBox(height: 16), CustomTextField( label: 'Extra Parameters', - value: extraParameters, + value: opts.extraParameters, onChanged: (val) { - setState(() => extraParameters = val); - _updateService(context); + cmdService.updateGeneralCastOptions(opts.copyWith(extraParameters: val)); + debugPrint('[CommonCommandsPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, tooltip: 'Add any additional scrcpy command-line parameters not covered by the GUI options above.', ), diff --git a/ScrcpyGui/lib/pages/home_panels/display_window_panel.dart b/ScrcpyGui/lib/pages/home_panels/display_window_panel.dart index a825632..153ab4e 100644 --- a/ScrcpyGui/lib/pages/home_panels/display_window_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/display_window_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; import '../../widgets/custom_searchbar.dart'; @@ -36,16 +37,6 @@ class DisplayWindowPanel extends StatefulWidget { } class _DisplayWindowPanelState extends State { - String windowX = ''; - String windowY = ''; - String windowWidth = ''; - String windowHeight = ''; - String? rotation; - String displayId = ''; - String displayBuffer = ''; - String? renderDriver; - bool forceAdbForward = false; - final List rotationOptions = ['0', '1', '2', '3']; final List renderDriverOptions = [ 'direct3d', @@ -58,54 +49,22 @@ class _DisplayWindowPanelState extends State { 'software', ]; - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.displayWindowOptions.copyWith( - windowX: windowX, - windowY: windowY, - windowWidth: windowWidth, - windowHeight: windowHeight, - rotation: rotation ?? '', - displayId: displayId, - displayBuffer: displayBuffer, - renderDriver: renderDriver ?? '', - forceAdbForward: forceAdbForward, - ); - - cmdService.updateDisplayWindowOptions(options); - - debugPrint( - '[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - windowX = ''; - windowY = ''; - windowWidth = ''; - windowHeight = ''; - rotation = null; - displayId = ''; - displayBuffer = ''; - renderDriver = null; - forceAdbForward = false; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.displayWindowOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.crop_square, title: 'Display/Window', showButton: true, panelType: "Display/Window", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateDisplayWindowOptions(const DisplayWindowOptions()); + debugPrint('[DisplayWindowPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -116,10 +75,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomTextField( label: 'Window X Position', - value: windowX, + value: opts.windowX, onChanged: (val) { - setState(() => windowX = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(windowX: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the initial window horizontal position. Default is "auto".', ), @@ -128,10 +87,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomTextField( label: 'Window Y Position', - value: windowY, + value: opts.windowY, onChanged: (val) { - setState(() => windowY = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(windowY: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the initial window vertical position. Default is "auto".', ), @@ -140,10 +99,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomTextField( label: 'Window Width', - value: windowWidth, + value: opts.windowWidth, onChanged: (val) { - setState(() => windowWidth = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(windowWidth: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the initial window width. Default is 0 (automatic).', ), @@ -152,10 +111,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomTextField( label: 'Window Height', - value: windowHeight, + value: opts.windowHeight, onChanged: (val) { - setState(() => windowHeight = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(windowHeight: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the initial window height. Default is 0 (automatic).', ), @@ -168,15 +127,15 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Rotation (0=0°, 1=90°, 2=180°, 3=270°)', - value: rotation, + value: opts.rotation.isNotEmpty ? opts.rotation : null, suggestions: rotationOptions, onChanged: (val) { - setState(() => rotation = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(rotation: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => rotation = null); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(rotation: '')); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Rotate the video content by 90° increments (0, 1, 2, or 3 for 0°, 90°, 180°, 270° clockwise).', ), @@ -185,10 +144,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomTextField( label: 'Display ID', - value: displayId, + value: opts.displayId, onChanged: (val) { - setState(() => displayId = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(displayId: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Specify the device display id to mirror. The available display ids can be listed by: scrcpy --list-displays. Default is 0.', ), @@ -197,10 +156,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomTextField( label: 'Display Buffer (ms)', - value: displayBuffer, + value: opts.displayBuffer, onChanged: (val) { - setState(() => displayBuffer = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(displayBuffer: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Add a buffering delay (in milliseconds) before displaying video frames. This increases latency to compensate for jitter. Default is 0 (no buffering).', ), @@ -214,15 +173,15 @@ class _DisplayWindowPanelState extends State { flex: 2, child: CustomSearchBar( hintText: 'Render Driver', - value: renderDriver, + value: opts.renderDriver.isNotEmpty ? opts.renderDriver : null, suggestions: renderDriverOptions, onChanged: (val) { - setState(() => renderDriver = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(renderDriver: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => renderDriver = null); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(renderDriver: '')); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Request SDL to use the given render driver (this is just a hint). Supported names: direct3d, opengl, opengles2, opengles, metal, software.', ), @@ -231,10 +190,10 @@ class _DisplayWindowPanelState extends State { Expanded( child: CustomCheckbox( label: 'Force ADB Forward', - value: forceAdbForward, + value: opts.forceAdbForward, onChanged: (val) { - setState(() => forceAdbForward = val); - _updateService(context); + cmdService.updateDisplayWindowOptions(opts.copyWith(forceAdbForward: val)); + debugPrint('[DisplayWindowPanel] Updated DisplayWindowOptions → ${cmdService.fullCommand}'); }, tooltip: 'Do not attempt to use "adb reverse" to connect to the device.', ), diff --git a/ScrcpyGui/lib/pages/home_panels/input_control_panel.dart b/ScrcpyGui/lib/pages/home_panels/input_control_panel.dart index 0a34f9f..5ce2cbc 100644 --- a/ScrcpyGui/lib/pages/home_panels/input_control_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/input_control_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; import '../../widgets/custom_searchbar.dart'; @@ -37,17 +38,6 @@ class InputControlPanel extends StatefulWidget { } class _InputControlPanelState extends State { - bool noControl = false; - bool noMouseHover = false; - bool forwardAllClicks = false; - bool legacyPaste = false; - bool noKeyRepeat = false; - bool rawKeyEvents = false; - bool preferText = false; - String? mouseBind; - String? keyboardMode; - String? mouseMode; - final List mouseBindOptions = [ 'bhsm', 'bhms', @@ -71,56 +61,22 @@ class _InputControlPanelState extends State { 'disabled', ]; - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.inputControlOptions.copyWith( - keyboardMode: keyboardMode ?? '', - mouseMode: mouseMode ?? '', - noControl: noControl, - noMouseHover: noMouseHover, - forwardAllClicks: forwardAllClicks, - legacyPaste: legacyPaste, - noKeyRepeat: noKeyRepeat, - rawKeyEvents: rawKeyEvents, - preferText: preferText, - mouseBind: mouseBind ?? '', - ); - - cmdService.updateInputControlOptions(options); - - debugPrint( - '[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - keyboardMode = null; - mouseMode = null; - noControl = false; - noMouseHover = false; - forwardAllClicks = false; - legacyPaste = false; - noKeyRepeat = false; - rawKeyEvents = false; - preferText = false; - mouseBind = null; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.inputControlOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.gamepad, title: 'Input Control', showButton: true, panelType: "Input Control", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateInputControlOptions(const InputControlOptions()); + debugPrint('[InputControlPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -131,15 +87,15 @@ class _InputControlPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Keyboard Mode', - value: keyboardMode, + value: opts.keyboardMode.isNotEmpty ? opts.keyboardMode : null, suggestions: keyboardModeOptions, onChanged: (val) { - setState(() => keyboardMode = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(keyboardMode: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => keyboardMode = null); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(keyboardMode: '')); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Select how to send keyboard inputs: disabled, sdk (Android API), uhid (physical HID keyboard), or aoa (AOAv2 protocol, USB only).', ), @@ -148,15 +104,15 @@ class _InputControlPanelState extends State { Expanded( child: CustomSearchBar( hintText: 'Mouse Mode', - value: mouseMode, + value: opts.mouseMode.isNotEmpty ? opts.mouseMode : null, suggestions: mouseModeOptions, onChanged: (val) { - setState(() => mouseMode = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(mouseMode: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => mouseMode = null); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(mouseMode: '')); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Select how to send mouse inputs: disabled, sdk (Android API), uhid (physical HID mouse), or aoa (AOAv2 protocol, USB only).', ), @@ -169,10 +125,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'No Control (View Only)', - value: noControl, + value: opts.noControl, onChanged: (val) { - setState(() => noControl = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(noControl: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Disable device control (mirror the device in read-only).', ), @@ -181,10 +137,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'No Mouse Hover', - value: noMouseHover, + value: opts.noMouseHover, onChanged: (val) { - setState(() => noMouseHover = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(noMouseHover: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Do not forward mouse hover (mouse motion without any clicks) events.', ), @@ -193,10 +149,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'Forward All Clicks', - value: forwardAllClicks, + value: opts.forwardAllClicks, onChanged: (val) { - setState(() => forwardAllClicks = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(forwardAllClicks: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Forward all mouse clicks to the device, including secondary buttons.', ), @@ -209,10 +165,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'Legacy Paste', - value: legacyPaste, + value: opts.legacyPaste, onChanged: (val) { - setState(() => legacyPaste = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(legacyPaste: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Inject computer clipboard text as a sequence of key events on Ctrl+v. This is a workaround for some devices not behaving as expected when setting the device clipboard programmatically.', ), @@ -221,10 +177,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'No Key Repeat', - value: noKeyRepeat, + value: opts.noKeyRepeat, onChanged: (val) { - setState(() => noKeyRepeat = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(noKeyRepeat: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Do not forward repeated key events when a key is held down.', ), @@ -233,10 +189,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'Raw Key Events', - value: rawKeyEvents, + value: opts.rawKeyEvents, onChanged: (val) { - setState(() => rawKeyEvents = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(rawKeyEvents: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Inject key events for all input keys, and ignore text events.', ), @@ -249,10 +205,10 @@ class _InputControlPanelState extends State { Expanded( child: CustomCheckbox( label: 'Prefer Text Injection', - value: preferText, + value: opts.preferText, onChanged: (val) { - setState(() => preferText = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(preferText: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Inject alpha characters and space as text events instead of key events. This avoids issues when combining multiple keys to enter a special character, but breaks the expected behavior of alpha keys in games (typically WASD).', ), @@ -262,15 +218,15 @@ class _InputControlPanelState extends State { flex: 2, child: CustomSearchBar( hintText: 'Mouse Bind (Button mapping)', - value: mouseBind, + value: opts.mouseBind.isNotEmpty ? opts.mouseBind : null, suggestions: mouseBindOptions, onChanged: (val) { - setState(() => mouseBind = val); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(mouseBind: val)); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => mouseBind = null); - _updateService(context); + cmdService.updateInputControlOptions(opts.copyWith(mouseBind: '')); + debugPrint('[InputControlPanel] Updated InputControlOptions → ${cmdService.fullCommand}'); }, tooltip: 'Configure bindings of secondary clicks. Each character maps a mouse button: + (forward), - (ignore), b (BACK), h (HOME), s (APP_SWITCH), n (notifications).', ), diff --git a/ScrcpyGui/lib/pages/home_panels/network_connection_panel.dart b/ScrcpyGui/lib/pages/home_panels/network_connection_panel.dart index 2e479e3..b90fb4a 100644 --- a/ScrcpyGui/lib/pages/home_panels/network_connection_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/network_connection_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; import '../../widgets/custom_textinput.dart'; @@ -35,52 +36,22 @@ class NetworkConnectionPanel extends StatefulWidget { } class _NetworkConnectionPanelState extends State { - String tcpipPort = ''; - bool selectTcpip = false; - String tunnelHost = ''; - String tunnelPort = ''; - bool noAdbForward = false; - - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.networkConnectionOptions.copyWith( - tcpipPort: tcpipPort, - selectTcpip: selectTcpip, - tunnelHost: tunnelHost, - tunnelPort: tunnelPort, - noAdbForward: noAdbForward, - ); - - cmdService.updateNetworkConnectionOptions(options); - - debugPrint( - '[NetworkConnectionPanel] Updated NetworkConnectionOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - tcpipPort = ''; - selectTcpip = false; - tunnelHost = ''; - tunnelPort = ''; - noAdbForward = false; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.networkConnectionOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.wifi, title: 'Network/Connection', showButton: true, panelType: "Network/Connection", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateNetworkConnectionOptions(const NetworkConnectionOptions()); + debugPrint('[NetworkConnectionPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -91,10 +62,10 @@ class _NetworkConnectionPanelState extends State { Expanded( child: CustomTextField( label: 'TCP/IP Port', - value: tcpipPort, + value: opts.tcpipPort, onChanged: (val) { - setState(() => tcpipPort = val); - _updateService(context); + cmdService.updateNetworkConnectionOptions(opts.copyWith(tcpipPort: val)); + debugPrint('[NetworkConnectionPanel] Updated NetworkConnectionOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the TCP port (range) used by the client to listen. Default is 27183:27199.', ), @@ -103,10 +74,10 @@ class _NetworkConnectionPanelState extends State { Expanded( child: CustomCheckbox( label: 'Select TCP/IP Device', - value: selectTcpip, + value: opts.selectTcpip, onChanged: (val) { - setState(() => selectTcpip = val); - _updateService(context); + cmdService.updateNetworkConnectionOptions(opts.copyWith(selectTcpip: val)); + debugPrint('[NetworkConnectionPanel] Updated NetworkConnectionOptions → ${cmdService.fullCommand}'); }, tooltip: 'Use TCP/IP device (if there is exactly one, like adb -e).', ), @@ -115,10 +86,10 @@ class _NetworkConnectionPanelState extends State { Expanded( child: CustomCheckbox( label: 'Disable ADB Forward', - value: noAdbForward, + value: opts.noAdbForward, onChanged: (val) { - setState(() => noAdbForward = val); - _updateService(context); + cmdService.updateNetworkConnectionOptions(opts.copyWith(noAdbForward: val)); + debugPrint('[NetworkConnectionPanel] Updated NetworkConnectionOptions → ${cmdService.fullCommand}'); }, tooltip: 'Do not attempt to use "adb reverse" to connect to the device.', ), @@ -131,10 +102,10 @@ class _NetworkConnectionPanelState extends State { Expanded( child: CustomTextField( label: 'SSH Tunnel Host', - value: tunnelHost, + value: opts.tunnelHost, onChanged: (val) { - setState(() => tunnelHost = val); - _updateService(context); + cmdService.updateNetworkConnectionOptions(opts.copyWith(tunnelHost: val)); + debugPrint('[NetworkConnectionPanel] Updated NetworkConnectionOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the IP address of the adb tunnel to reach the scrcpy server. This option automatically enables --force-adb-forward. Default is localhost.', ), @@ -143,10 +114,10 @@ class _NetworkConnectionPanelState extends State { Expanded( child: CustomTextField( label: 'SSH Tunnel Port', - value: tunnelPort, + value: opts.tunnelPort, onChanged: (val) { - setState(() => tunnelPort = val); - _updateService(context); + cmdService.updateNetworkConnectionOptions(opts.copyWith(tunnelPort: val)); + debugPrint('[NetworkConnectionPanel] Updated NetworkConnectionOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the TCP port of the adb tunnel to reach the scrcpy server. This option automatically enables --force-adb-forward. Default is 0 (not forced).', ), diff --git a/ScrcpyGui/lib/pages/home_panels/otg_mode_panel.dart b/ScrcpyGui/lib/pages/home_panels/otg_mode_panel.dart index 9b12c5e..405542f 100644 --- a/ScrcpyGui/lib/pages/home_panels/otg_mode_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/otg_mode_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; import '../../widgets/surrounding_panel.dart'; @@ -34,46 +35,22 @@ class OtgModePanel extends StatefulWidget { } class _OtgModePanelState extends State { - bool otg = false; - bool hidKeyboard = false; - bool hidMouse = false; - - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.otgModeOptions.copyWith( - otg: otg, - hidKeyboard: hidKeyboard, - hidMouse: hidMouse, - ); - - cmdService.updateOtgModeOptions(options); - - debugPrint( - '[OtgModePanel] Updated OtgModeOptions → ${cmdService.fullCommand}', - ); - } - - void _clearAllFields() { - setState(() { - otg = false; - hidKeyboard = false; - hidMouse = false; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.otgModeOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.usb, title: 'OTG Mode', showButton: true, panelType: "OTG Mode", - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateOtgModeOptions(const OtgModeOptions()); + debugPrint('[OtgModePanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -84,10 +61,10 @@ class _OtgModePanelState extends State { Expanded( child: CustomCheckbox( label: 'Enable OTG Mode', - value: otg, + value: opts.otg, onChanged: (val) { - setState(() => otg = val); - _updateService(context); + cmdService.updateOtgModeOptions(opts.copyWith(otg: val)); + debugPrint('[OtgModePanel] Updated OtgModeOptions → ${cmdService.fullCommand}'); }, tooltip: 'Run in OTG mode: simulate physical keyboard and mouse, as if the computer keyboard and mouse were plugged directly to the device via an OTG cable. In this mode, adb (USB debugging) is not necessary, and mirroring is disabled.', ), @@ -96,10 +73,10 @@ class _OtgModePanelState extends State { Expanded( child: CustomCheckbox( label: 'HID Keyboard', - value: hidKeyboard, + value: opts.hidKeyboard, onChanged: (val) { - setState(() => hidKeyboard = val); - _updateService(context); + cmdService.updateOtgModeOptions(opts.copyWith(hidKeyboard: val)); + debugPrint('[OtgModePanel] Updated OtgModeOptions → ${cmdService.fullCommand}'); }, tooltip: 'Simulate a physical HID keyboard. Keyboard may be disabled separately using --keyboard=disabled.', ), @@ -108,10 +85,10 @@ class _OtgModePanelState extends State { Expanded( child: CustomCheckbox( label: 'HID Mouse', - value: hidMouse, + value: opts.hidMouse, onChanged: (val) { - setState(() => hidMouse = val); - _updateService(context); + cmdService.updateOtgModeOptions(opts.copyWith(hidMouse: val)); + debugPrint('[OtgModePanel] Updated OtgModeOptions → ${cmdService.fullCommand}'); }, tooltip: 'Simulate a physical HID mouse. Mouse may be disabled separately using --mouse=disabled.', ), diff --git a/ScrcpyGui/lib/pages/home_panels/package_selector_panel.dart b/ScrcpyGui/lib/pages/home_panels/package_selector_panel.dart index 060f863..fdab3cf 100644 --- a/ScrcpyGui/lib/pages/home_panels/package_selector_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/package_selector_panel.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/device_manager_service.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_searchbar.dart'; import '../../widgets/surrounding_panel.dart'; @@ -16,18 +17,18 @@ class PackageSelectorPanel extends StatefulWidget { } class _PackageSelectorPanelState extends State { - String selectedPackage = ''; - String selectedAppName = ''; + // Local state for packages is fine as it's data source, not configuration state List packages = []; Map packageLabels = {}; // package -> app name Map reverseLabels = {}; // app name -> package - DeviceManagerService? _deviceManager; // Add this field + DeviceManagerService? _deviceManager; @override void initState() { super.initState(); _loadPackages(); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; _deviceManager = Provider.of( context, listen: false, @@ -47,53 +48,40 @@ class _PackageSelectorPanelState extends State { ); final deviceId = deviceManager.selectedDevice; if (deviceId == null) { - setState(() { - packages = []; - packageLabels = {}; - reverseLabels = {}; - }); + if (mounted) { + setState(() { + packages = []; + packageLabels = {}; + reverseLabels = {}; + }); + } return; } final info = DeviceManagerService.devicesInfo[deviceId]; if (info != null) { - setState(() { - packages = info.packages; - packageLabels = info.packageLabels; - // Create reverse mapping: app name -> package name - reverseLabels = { - for (var entry in info.packageLabels.entries) entry.value: entry.key - }; - }); + if (mounted) { + setState(() { + packages = info.packages; + packageLabels = info.packageLabels; + // Create reverse mapping: app name -> package name + reverseLabels = { + for (var entry in info.packageLabels.entries) entry.value: entry.key + }; + }); + } } else { - setState(() { - packages = []; - packageLabels = {}; - reverseLabels = {}; - }); + if (mounted) { + setState(() { + packages = []; + packageLabels = {}; + reverseLabels = {}; + }); + } } } - void _updateCommandBuilder() { - final cmdService = Provider.of( - context, - listen: false, - ); - cmdService.updateGeneralCastOptions( - cmdService.generalCastOptions.copyWith(selectedPackage: selectedPackage), - ); - } - - void _clearAllFields() { - setState(() { - selectedPackage = ''; - selectedAppName = ''; - }); - _updateCommandBuilder(); - } - @override void dispose() { - // Use the saved reference instead of Provider.of(context) _deviceManager?.selectedDeviceNotifier.removeListener(_onDeviceChanged); super.dispose(); } @@ -103,13 +91,27 @@ class _PackageSelectorPanelState extends State { // Get list of app names for display final appNames = packageLabels.values.toList()..sort(); + final opts = context.select( + (s) => s.generalCastOptions, + ); + final cmdService = context.read(); + + final selectedPackage = opts.selectedPackage; + // Derive the app name from the selected package, or use the package name if not found + final selectedAppName = packageLabels[selectedPackage] ?? selectedPackage; + return SurroundingPanel( title: "Applications", icon: Icons.apps, panelType: "Package Selector", showButton: false, clearController: widget.clearController, - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateGeneralCastOptions( + opts.copyWith(selectedPackage: ''), + ); + debugPrint('[PackageSelectorPanel] Fields cleared!'); + }, child: Column( children: [ const SizedBox(height: 20), @@ -118,19 +120,25 @@ class _PackageSelectorPanelState extends State { value: selectedAppName, suggestions: appNames, onChanged: (value) { - setState(() { - selectedAppName = value; - // Convert app name to package name for the command builder - selectedPackage = reverseLabels[value] ?? value; - }); - _updateCommandBuilder(); + // If the user types an app name, try to find its package + // If not found in reverseLabels, assume it's a raw package name (or partial) + // The CustomSearchBar likely returns the text in the field. + // If the user selected from suggestions, value will be an App Name. + // If the user typed manually, it might be anything. + + // Ideally, we want to map back to package name. + final pkg = reverseLabels[value] ?? value; + + cmdService.updateGeneralCastOptions( + opts.copyWith(selectedPackage: pkg), + ); + debugPrint('[PackageSelectorPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() { - selectedPackage = ''; - selectedAppName = ''; - }); - _updateCommandBuilder(); + cmdService.updateGeneralCastOptions( + opts.copyWith(selectedPackage: ''), + ); + debugPrint('[PackageSelectorPanel] Updated GeneralCastOptions → ${cmdService.fullCommand}'); }, onReload: _loadPackages, ), diff --git a/ScrcpyGui/lib/pages/home_panels/recording_commands_panel.dart b/ScrcpyGui/lib/pages/home_panels/recording_commands_panel.dart index 1faf3ad..cbfef17 100644 --- a/ScrcpyGui/lib/pages/home_panels/recording_commands_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/recording_commands_panel.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; import '../../services/settings_service.dart'; import '../../utils/clear_notifier.dart'; import '../../widgets/custom_checkbox.dart'; @@ -44,12 +45,6 @@ class _RecordingCommandsPanelState extends State { final List orientations = ['0', '90', '180', '270']; final SettingsService _settingsService = SettingsService(); - bool enableRecording = false; - String fileName = ''; - String outputFormat = ''; - String maxFps = ''; - String maxSize = ''; - String recordOrientation = ''; String recordingsDirectory = ''; @override @@ -60,7 +55,11 @@ class _RecordingCommandsPanelState extends State { Future _loadRecordingsDirectory() async { final settings = await _settingsService.loadSettings(); - recordingsDirectory = settings.recordingsDirectory; + if (mounted) { + setState(() { + recordingsDirectory = settings.recordingsDirectory; + }); + } // Create directory if it doesn't exist if (recordingsDirectory.isNotEmpty) { @@ -71,28 +70,7 @@ class _RecordingCommandsPanelState extends State { } } - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.recordingOptions.copyWith( - outputFile: fileName, - outputFormat: outputFormat, - framerate: maxFps, - maxSize: maxSize, - recordOrientation: recordOrientation, - ); - - cmdService.updateRecordingOptions(options); - - debugPrint( - '[RecordingPanel] Updated RecordingOptions → ${cmdService.fullCommand}', - ); - } - - void _initializeRecordingOptions(BuildContext context) { + void _initializeRecordingOptions(CommandBuilderService cmdService) { final now = DateTime.now(); final formattedDateTime = "${now.year}_${now.month.toString().padLeft(2, '0')}_${now.day.toString().padLeft(2, '0')}_${now.hour.toString().padLeft(2, '0')}_${now.minute.toString().padLeft(2, '0')}_${now.second.toString().padLeft(2, '0')}"; @@ -100,48 +78,45 @@ class _RecordingCommandsPanelState extends State { final recordingsDir = SettingsService.currentSettings?.recordingsDirectory ?? ''; - setState(() { - fileName = "$recordingsDir/Scrcpy_$formattedDateTime"; - outputFormat = "mp4"; - maxFps = "30"; - maxSize = ""; - }); - - _updateService(context); - } - - void _cleanSettings(BuildContext context) { - setState(() { - fileName = ""; - outputFormat = ""; - maxFps = ""; - maxSize = ""; - recordOrientation = ""; - }); - - _updateService(context); + final newOptions = cmdService.recordingOptions.copyWith( + outputFile: "$recordingsDir/Scrcpy_$formattedDateTime", + outputFormat: "mp4", + framerate: "30", + maxSize: "", + ); + cmdService.updateRecordingOptions(newOptions); + debugPrint('[RecordingCommandsPanel] Recording initialized → ${cmdService.fullCommand}'); } - void _clearAllFields() { - setState(() { - enableRecording = false; - fileName = ''; - outputFormat = ''; - maxFps = ''; - maxSize = ''; - recordOrientation = ''; - }); - _updateService(context); + void _cleanSettings(CommandBuilderService cmdService) { + final newOptions = cmdService.recordingOptions.copyWith( + outputFile: "", + outputFormat: "", + framerate: "", + maxSize: "", + recordOrientation: "", + ); + cmdService.updateRecordingOptions(newOptions); + debugPrint('[RecordingCommandsPanel] Recording settings cleaned → ${cmdService.fullCommand}'); } @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.recordingOptions, + ); + final cmdService = context.read(); + final enableRecording = opts.outputFile.isNotEmpty; + return SurroundingPanel( icon: Icons.videocam, title: 'Recording', panelType: "Recording", showButton: true, - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateRecordingOptions(const ScreenRecordingOptions()); + debugPrint('[RecordingCommandsPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -156,11 +131,10 @@ class _RecordingCommandsPanelState extends State { label: 'Enable Recording', value: enableRecording, onChanged: (val) { - setState(() => enableRecording = val); if (val) { - _initializeRecordingOptions(context); + _initializeRecordingOptions(cmdService); } else { - _cleanSettings(context); + _cleanSettings(cmdService); } }, tooltip: 'Record screen to file. The format is determined by the --record-format option if set, or by the file extension.', @@ -175,10 +149,12 @@ class _RecordingCommandsPanelState extends State { opacity: enableRecording ? 1.0 : 0.5, child: CustomTextField( label: 'File Name', - value: fileName, + value: opts.outputFile, onChanged: (val) { - setState(() => fileName = val); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(outputFile: val), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the file path for recording. The format is determined by the file extension or the output format option.', ), @@ -201,14 +177,18 @@ class _RecordingCommandsPanelState extends State { hintText: 'Select format', suggestions: outputFormats, onChanged: (val) { - setState(() => outputFormat = val); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(outputFormat: val), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => outputFormat = ''); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(outputFormat: ''), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, - value: outputFormat, + value: opts.outputFormat, tooltip: 'Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).', ), ), @@ -222,10 +202,12 @@ class _RecordingCommandsPanelState extends State { opacity: enableRecording ? 1.0 : 0.5, child: CustomTextField( label: 'Max fps', - value: maxFps, + value: opts.framerate, onChanged: (val) { - setState(() => maxFps = val); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(framerate: val), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, tooltip: 'Limit the frame rate of screen capture (officially supported since Android 10, but may work on earlier versions).', ), @@ -240,10 +222,12 @@ class _RecordingCommandsPanelState extends State { opacity: enableRecording ? 1.0 : 0.5, child: CustomTextField( label: 'Max Size', - value: maxSize, + value: opts.maxSize, onChanged: (val) { - setState(() => maxSize = val); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(maxSize: val), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, tooltip: 'Limit both the width and height of the video to value. The other dimension is computed so that the device aspect-ratio is preserved. Default is 0 (unlimited).', ), @@ -266,14 +250,18 @@ class _RecordingCommandsPanelState extends State { hintText: 'Record Orientation', suggestions: orientations, onChanged: (val) { - setState(() => recordOrientation = val); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(recordOrientation: val), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => recordOrientation = ''); - _updateService(context); + cmdService.updateRecordingOptions( + opts.copyWith(recordOrientation: ''), + ); + debugPrint('[RecordingCommandsPanel] Updated ScreenRecordingOptions → ${cmdService.fullCommand}'); }, - value: recordOrientation, + value: opts.recordOrientation, tooltip: 'Set the record orientation. The number represents the clockwise rotation in degrees (0, 90, 180, 270). Default is 0.', ), ), diff --git a/ScrcpyGui/lib/pages/home_panels/virtual_display_commands_panel.dart b/ScrcpyGui/lib/pages/home_panels/virtual_display_commands_panel.dart index dad659a..2595a17 100644 --- a/ScrcpyGui/lib/pages/home_panels/virtual_display_commands_panel.dart +++ b/ScrcpyGui/lib/pages/home_panels/virtual_display_commands_panel.dart @@ -12,6 +12,7 @@ import '../../widgets/custom_searchbar.dart'; import '../../widgets/custom_textinput.dart'; import '../../widgets/surrounding_panel.dart'; import '../../services/command_builder_service.dart'; +import '../../models/scrcpy_options.dart'; /// Panel for configuring virtual display options. /// @@ -47,77 +48,22 @@ class _VirtualDisplayCommandsPanelState '1024x768', ]; - bool newDisplay = false; - bool noDisplayDecorations = false; - bool dontDestroyContent = false; - String resolution = ''; - String dpi = ''; - - void _updateService(BuildContext context) { - final cmdService = Provider.of( - context, - listen: false, - ); - - final options = cmdService.virtualDisplayOptions.copyWith( - newDisplay: newDisplay, - noVdSystemDecorations: noDisplayDecorations, - noVdDestroyContent: dontDestroyContent, - resolution: resolution, - dpi: dpi, - ); - - cmdService.updateVirtualDisplayOptions(options); - - debugPrint( - '[VirtualDisplayPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}', - ); - } - - void _cleanSettings(BuildContext context) { - setState(() { - resolution = ''; - dpi = ''; - noDisplayDecorations = false; - dontDestroyContent = false; - }); - _updateService(context); - } - - void _onNewDisplayChanged(BuildContext context, bool enabled) { - setState(() { - newDisplay = enabled; - if (enabled && resolution.isEmpty) { - resolution = resolutionOptions.first; - } - }); - - if (!enabled) { - _cleanSettings(context); - } - - _updateService(context); - } - - void _clearAllFields() { - setState(() { - newDisplay = false; - noDisplayDecorations = false; - dontDestroyContent = false; - resolution = ''; - dpi = ''; - }); - _updateService(context); - } - @override Widget build(BuildContext context) { + final opts = context.select( + (s) => s.virtualDisplayOptions, + ); + final cmdService = context.read(); + return SurroundingPanel( icon: Icons.monitor, title: 'Virtual Display', panelType: "Virtual Display", showButton: true, - onClearPressed: _clearAllFields, + onClearPressed: () { + cmdService.updateVirtualDisplayOptions(const VirtualDisplayOptions()); + debugPrint('[VirtualDisplayCommandsPanel] Fields cleared!'); + }, clearController: widget.clearController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -129,30 +75,49 @@ class _VirtualDisplayCommandsPanelState Expanded( child: CustomCheckbox( label: 'New Display', - value: newDisplay, + value: opts.newDisplay, onChanged: (val) { - _onNewDisplayChanged(context, val); + var newOpts = opts.copyWith(newDisplay: val); + if (val && opts.resolution.isEmpty) { + newOpts = newOpts.copyWith( + resolution: resolutionOptions.first, + ); + } else if (!val) { + newOpts = newOpts.copyWith( + resolution: '', + dpi: '', + noVdSystemDecorations: false, + noVdDestroyContent: false, + ); + } + cmdService.updateVirtualDisplayOptions(newOpts); + debugPrint('[VirtualDisplayCommandsPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}'); }, - tooltip: 'Create a new display with the specified resolution and density. If not provided, they default to the main display dimensions and DPI.', + tooltip: + 'Create a new display with the specified resolution and density. If not provided, they default to the main display dimensions and DPI.', ), ), const SizedBox(width: 16), Expanded( child: AbsorbPointer( - absorbing: !newDisplay, + absorbing: !opts.newDisplay, child: Opacity( - opacity: newDisplay ? 1.0 : 0.5, + opacity: opts.newDisplay ? 1.0 : 0.5, child: CustomSearchBar( hintText: 'Resolution', suggestions: resolutionOptions, - value: resolution, + value: opts.resolution, onChanged: (val) { - setState(() => resolution = val); - _updateService(context); + cmdService.updateVirtualDisplayOptions( + opts.copyWith(resolution: val), + ); + debugPrint('[VirtualDisplayCommandsPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}'); }, onClear: () { - setState(() => resolution = ''); - _updateService(context); + cmdService.updateVirtualDisplayOptions( + opts.copyWith(resolution: ''), + ); + debugPrint('[VirtualDisplayCommandsPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the resolution for the new display (e.g., 1920x1080). Defaults to the main display dimensions.', ), @@ -162,15 +127,17 @@ class _VirtualDisplayCommandsPanelState const SizedBox(width: 16), Expanded( child: AbsorbPointer( - absorbing: !newDisplay, + absorbing: !opts.newDisplay, child: Opacity( - opacity: newDisplay ? 1.0 : 0.5, + opacity: opts.newDisplay ? 1.0 : 0.5, child: CustomCheckbox( label: "Don't Destroy Content", - value: dontDestroyContent, + value: opts.noVdDestroyContent, onChanged: (val) { - setState(() => dontDestroyContent = val); - _updateService(context); + cmdService.updateVirtualDisplayOptions( + opts.copyWith(noVdDestroyContent: val), + ); + debugPrint('[VirtualDisplayCommandsPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}'); }, tooltip: 'Disable virtual display "destroy content on removal" flag. With this option, when the virtual display is closed, the running apps are moved to the main display rather than being destroyed.', ), @@ -186,15 +153,17 @@ class _VirtualDisplayCommandsPanelState children: [ Expanded( child: AbsorbPointer( - absorbing: !newDisplay, + absorbing: !opts.newDisplay, child: Opacity( - opacity: newDisplay ? 1.0 : 0.5, + opacity: opts.newDisplay ? 1.0 : 0.5, child: CustomCheckbox( label: 'No Display Decorations', - value: noDisplayDecorations, + value: opts.noVdSystemDecorations, onChanged: (val) { - setState(() => noDisplayDecorations = val); - _updateService(context); + cmdService.updateVirtualDisplayOptions( + opts.copyWith(noVdSystemDecorations: val), + ); + debugPrint('[VirtualDisplayCommandsPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}'); }, tooltip: 'Disable virtual display system decorations flag.', ), @@ -204,15 +173,17 @@ class _VirtualDisplayCommandsPanelState const SizedBox(width: 16), Expanded( child: AbsorbPointer( - absorbing: !newDisplay, + absorbing: !opts.newDisplay, child: Opacity( - opacity: newDisplay ? 1.0 : 0.5, + opacity: opts.newDisplay ? 1.0 : 0.5, child: CustomTextField( label: 'Dots Per Inch (DPI)', - value: dpi, + value: opts.dpi, onChanged: (val) { - setState(() => dpi = val); - _updateService(context); + cmdService.updateVirtualDisplayOptions( + opts.copyWith(dpi: val), + ); + debugPrint('[VirtualDisplayCommandsPanel] Updated VirtualDisplayOptions → ${cmdService.fullCommand}'); }, tooltip: 'Set the DPI for the new display. Defaults to the main display DPI.', ), diff --git a/ScrcpyGui/lib/services/command_builder_service.dart b/ScrcpyGui/lib/services/command_builder_service.dart index 099d9b7..b48b696 100644 --- a/ScrcpyGui/lib/services/command_builder_service.dart +++ b/ScrcpyGui/lib/services/command_builder_service.dart @@ -10,9 +10,11 @@ /// - Notify listeners when any option changes library; +import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/scrcpy_options.dart'; import 'device_manager_service.dart'; +import 'options_state_service.dart'; /// Service for building scrcpy commands from panel options /// @@ -28,36 +30,23 @@ class CommandBuilderService extends ChangeNotifier { /// The .exe extension is optional on Windows (resolved via PATHEXT) String baseCommand = "scrcpy --pause-on-exit=if-error"; - /// Audio configuration options (bitrate, codec, buffer, etc.) - AudioOptions audioOptions = AudioOptions(); + /// All option objects in a single immutable bundle (backing store for persistence) + OptionsBundle _options = const OptionsBundle(); - /// Screen recording options (output file, format, bitrate, etc.) - ScreenRecordingOptions recordingOptions = ScreenRecordingOptions(); + /// Single instance of the persistence service + final OptionsStateService _stateService = OptionsStateService(); - /// Virtual display options (resolution, DPI, decorations, etc.) - VirtualDisplayOptions virtualDisplayOptions = VirtualDisplayOptions(); - - /// General casting options (fullscreen, orientation, video codec, package, etc.) - GeneralCastOptions generalCastOptions = GeneralCastOptions(); - - /// Camera options (camera ID, size, facing, FPS, aspect ratio, etc.) - CameraOptions cameraOptions = CameraOptions(); - - /// Input control options (mouse, keyboard, paste behavior, etc.) - InputControlOptions inputControlOptions = InputControlOptions(); - - /// Display/Window configuration options (position, size, rotation, render driver, etc.) - DisplayWindowOptions displayWindowOptions = DisplayWindowOptions(); - - /// Network/Connection options (TCP/IP, tunneling, ADB forward, etc.) - NetworkConnectionOptions networkConnectionOptions = NetworkConnectionOptions(); - - - /// Advanced/Developer options (verbosity, cleanup, V4L2, etc.) - AdvancedOptions advancedOptions = AdvancedOptions(); - - /// OTG Mode options (OTG, HID keyboard/mouse) - OtgModeOptions otgModeOptions = OtgModeOptions(); + // Getters that delegate to the bundle — panels read these directly. + AudioOptions get audioOptions => _options.audioOptions; + ScreenRecordingOptions get recordingOptions => _options.recordingOptions; + VirtualDisplayOptions get virtualDisplayOptions => _options.virtualDisplayOptions; + GeneralCastOptions get generalCastOptions => _options.generalCastOptions; + CameraOptions get cameraOptions => _options.cameraOptions; + InputControlOptions get inputControlOptions => _options.inputControlOptions; + DisplayWindowOptions get displayWindowOptions => _options.displayWindowOptions; + NetworkConnectionOptions get networkConnectionOptions => _options.networkConnectionOptions; + AdvancedOptions get advancedOptions => _options.advancedOptions; + OtgModeOptions get otgModeOptions => _options.otgModeOptions; /// Reference to DeviceManagerService to get selected device DeviceManagerService? _deviceManagerService; @@ -83,70 +72,80 @@ class CommandBuilderService extends ChangeNotifier { @override void dispose() { - // Clean up listener + unawaited(flushPendingSave()); _deviceManagerService?.removeListener(_onDeviceChanged); super.dispose(); } void updateAudioOptions(AudioOptions options) { - audioOptions = options; + _options = _options.copyWith(audioOptions: options); _log('Audio options updated: $audioOptions'); notifyListeners(); + _scheduleSave(); } /// Also affects window title (adds 'record-' prefix) void updateRecordingOptions(ScreenRecordingOptions options) { - recordingOptions = options; + _options = _options.copyWith(recordingOptions: options); _log('Recording options updated: $recordingOptions'); notifyListeners(); + _scheduleSave(); } void updateVirtualDisplayOptions(VirtualDisplayOptions options) { - virtualDisplayOptions = options; + _options = _options.copyWith(virtualDisplayOptions: options); _log('Virtual display options updated: $virtualDisplayOptions'); notifyListeners(); + _scheduleSave(); } void updateGeneralCastOptions(GeneralCastOptions options) { - generalCastOptions = options; + _options = _options.copyWith(generalCastOptions: options); _log('General cast options updated: $generalCastOptions'); notifyListeners(); + _scheduleSave(); } void updateCameraOptions(CameraOptions options) { - cameraOptions = options; + _options = _options.copyWith(cameraOptions: options); _log('Camera options updated: $cameraOptions'); notifyListeners(); + _scheduleSave(); } void updateInputControlOptions(InputControlOptions options) { - inputControlOptions = options; + _options = _options.copyWith(inputControlOptions: options); _log('Input control options updated: $inputControlOptions'); notifyListeners(); + _scheduleSave(); } void updateDisplayWindowOptions(DisplayWindowOptions options) { - displayWindowOptions = options; + _options = _options.copyWith(displayWindowOptions: options); _log('Display/Window options updated: $displayWindowOptions'); notifyListeners(); + _scheduleSave(); } void updateNetworkConnectionOptions(NetworkConnectionOptions options) { - networkConnectionOptions = options; + _options = _options.copyWith(networkConnectionOptions: options); _log('Network/Connection options updated: $networkConnectionOptions'); notifyListeners(); + _scheduleSave(); } void updateAdvancedOptions(AdvancedOptions options) { - advancedOptions = options; + _options = _options.copyWith(advancedOptions: options); _log('Advanced options updated: $advancedOptions'); notifyListeners(); + _scheduleSave(); } void updateOtgModeOptions(OtgModeOptions options) { - otgModeOptions = options; + _options = _options.copyWith(otgModeOptions: options); _log('OTG mode options updated: $otgModeOptions'); notifyListeners(); + _scheduleSave(); } /// Builds complete scrcpy command from all panels @@ -207,18 +206,48 @@ class CommandBuilderService extends ChangeNotifier { /// Reset all options to defaults void resetToDefaults() { - audioOptions = AudioOptions(); - recordingOptions = ScreenRecordingOptions(); - virtualDisplayOptions = VirtualDisplayOptions(); - generalCastOptions = GeneralCastOptions(); - cameraOptions = CameraOptions(); - inputControlOptions = InputControlOptions(); - displayWindowOptions = DisplayWindowOptions(); - networkConnectionOptions = NetworkConnectionOptions(); - advancedOptions = AdvancedOptions(); - otgModeOptions = OtgModeOptions(); + _options = const OptionsBundle(); _log('All options reset to defaults'); notifyListeners(); + _scheduleSave(); + } + + // --- Persistence --- + + Timer? _saveTimer; + + /// Serialize all 10 option objects to a single JSON map. + Map optionsToJson() => _options.toJson(); + + /// Restore all 10 option objects from a JSON map. + /// Recording state is cleared since it's session-specific (stale filenames). + void loadOptionsFromJson(Map json) { + try { + _options = OptionsBundle.fromJson(json); + // Clear recording state — outputFile contains a session-specific timestamp + _options = _options.copyWith(recordingOptions: const ScreenRecordingOptions()); + _log('Options loaded from JSON'); + } catch (e) { + _log('Error deserializing options, falling back to defaults: $e'); + _options = const OptionsBundle(); + } + notifyListeners(); + } + + /// Schedule a debounced save to avoid excessive disk writes. + void _scheduleSave() { + _saveTimer?.cancel(); + _saveTimer = Timer(const Duration(milliseconds: 4000), () { + _stateService.saveOptionsState(optionsToJson()); + }); + } + + /// Flush any pending save immediately (used on app close). + Future flushPendingSave() async { + if (_saveTimer != null){ + _saveTimer?.cancel(); + await _stateService.saveOptionsState(optionsToJson()); + } } /// Internal logging helper for debugging diff --git a/ScrcpyGui/lib/services/commands_service.dart b/ScrcpyGui/lib/services/commands_service.dart index e128bde..d78bf7b 100644 --- a/ScrcpyGui/lib/services/commands_service.dart +++ b/ScrcpyGui/lib/services/commands_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../models/commands_model.dart'; +import '../utils/app_paths.dart'; class CommandsService { static const String _commandsFileName = 'commands.json'; @@ -11,26 +12,8 @@ class CommandsService { static CommandsData? get currentCommands => _cachedCommands; Future get _commandsPath async { - final settingsDir = await _getSettingsDirectory(); - return p.join(settingsDir, _commandsFileName); - } - - /// Returns the app settings directory (same as SettingsService) - Future _getSettingsDirectory() async { - String dir; - if (Platform.isWindows) { - dir = Platform.environment['APPDATA'] ?? '.'; - } else if (Platform.isMacOS) { - dir = '${Platform.environment['HOME']}/Library/Application Support'; - } else { - dir = Platform.environment['HOME'] ?? '.'; - } - final fullDir = p.join(dir, 'ScrcpyGui'); - final directory = Directory(fullDir); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - return fullDir; + final basePath = await AppPaths.getBasePath(); + return p.join(basePath, _commandsFileName); } Future loadCommands() async { diff --git a/ScrcpyGui/lib/services/options_state_service.dart b/ScrcpyGui/lib/services/options_state_service.dart new file mode 100644 index 0000000..c05bf10 --- /dev/null +++ b/ScrcpyGui/lib/services/options_state_service.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import '../utils/app_paths.dart'; + +/// Service for persisting and loading scrcpy option state to/from disk. +/// +/// Stores all 10 option objects as a single JSON file so that user +/// configuration survives tab switches and app restarts. +class OptionsStateService { + static const String _fileName = 'scrcpy_options_state.json'; + + /// Cached resolved file path (set after first resolution). + static String? _cachedFilePath; + + Future get _filePath async { + if (_cachedFilePath != null) return _cachedFilePath!; + final basePath = await AppPaths.getBasePath(); + _cachedFilePath = p.join(basePath, _fileName); + return _cachedFilePath!; + } + + Future?> loadOptionsState() async { + try { + final path = await _filePath; + final file = File(path); + + if (await file.exists()) { + final jsonString = await file.readAsString(); + return jsonDecode(jsonString) as Map; + } + } catch (e) { + debugPrint('Error loading options state: $e'); + } + return null; + } + + Future saveOptionsState(Map state) async { + try { + final path = await _filePath; + final file = File(path); + await file.writeAsString(jsonEncode(state)); + } catch (e) { + debugPrint('Error saving options state: $e'); + } + } +} diff --git a/ScrcpyGui/lib/services/settings_service.dart b/ScrcpyGui/lib/services/settings_service.dart index 3b4fd3a..8e4d430 100644 --- a/ScrcpyGui/lib/services/settings_service.dart +++ b/ScrcpyGui/lib/services/settings_service.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../models/settings_model.dart'; +import '../utils/app_paths.dart'; class SettingsService { static AppSettings? _cachedSettings; // Cache settings in memory @@ -79,22 +80,9 @@ class SettingsService { } } - /// Returns the app settings directory + /// Returns the app settings directory (delegates to AppPaths) Future getSettingsDirectory() async { - String dir; - if (Platform.isWindows) { - dir = Platform.environment['APPDATA'] ?? '.'; - } else if (Platform.isMacOS) { - dir = '${Platform.environment['HOME']}/Library/Application Support'; - } else { - dir = Platform.environment['HOME'] ?? '.'; - } - final fullDir = p.join(dir, 'ScrcpyGui'); - final directory = Directory(fullDir); - if (!await directory.exists()) { - await directory.create(recursive: true); - } - return fullDir; + return AppPaths.getBasePath(); } /// Reset only User Interface settings (panel order and properties) diff --git a/ScrcpyGui/lib/utils/app_paths.dart b/ScrcpyGui/lib/utils/app_paths.dart new file mode 100644 index 0000000..3ff6408 --- /dev/null +++ b/ScrcpyGui/lib/utils/app_paths.dart @@ -0,0 +1,30 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Centralized app data directory resolution. +/// +/// All services that need to read/write persistent files should use +/// [AppPaths.getBasePath] instead of calling [getApplicationSupportDirectory] +/// directly. The resolved path is cached after the first call. +class AppPaths { + static String? _basePath; + + /// Returns the base application data directory path and ensures it exists. + /// + /// On Windows this resolves to `%APPDATA%\ScrcpyGui`. + /// The result is cached after the first call. + static Future getBasePath() async { + if (_basePath != null) return _basePath!; + + final dir = await getApplicationSupportDirectory(); + _basePath = p.join(dir.path, 'ScrcpyGui'); + + final directory = Directory(_basePath!); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + return _basePath!; + } +} diff --git a/ScrcpyGui/pubspec.yaml b/ScrcpyGui/pubspec.yaml index 1cc4d19..b229d26 100644 --- a/ScrcpyGui/pubspec.yaml +++ b/ScrcpyGui/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: url_launcher: ^6.1.10 package_info_plus: ^8.0.0 desktop_drop: ^0.4.4 + json_annotation: ^4.9.0 + freezed_annotation: ^2.4.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -47,6 +49,9 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + json_serializable: ^6.8.0 + build_runner: ^2.4.13 + freezed: ^2.5.2 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is