From e1d49ba48dc38d0aba74f58be7e5538b1ae6e7a3 Mon Sep 17 00:00:00 2001 From: Shripal Jain Date: Thu, 12 Feb 2026 22:08:40 +0100 Subject: [PATCH] feat: replace adb start-server with track-devices for automatic device refresh - Replace one-shot `adb start-server` init with persistent `adb track-devices` streaming process to auto-detect device connect/disconnect events - Add InitDeviceTracking and RestartTracking events to DevicesBloc with full process lifecycle management (start, stop, restart on close) - Generalize RunningProcessManager from Scrcpy-specific types to generic ProcessStopResult/ProcessStopError for reuse across adb and scrcpy processes - Rename ScrcpyKillError to ScrcpyStopError for naming consistency - Extract navigation pane logic from HomeScreen into NavigationService - Wire HomeScreen to listen for device tracking state via BlocConsumer and subscribe to track-devices stdout for automatic device list reload - Restart device tracking when ADB executable path changes in settings - Add i18n key for tracking failure error message --- assets/i18n/en.json | 3 + lib/application/adb_result_parser.dart | 12 +- .../extension/scrcpy_error_extension.dart | 4 +- lib/application/model/adb/adb_result.dart | 4 +- .../model/process/stop_result.dart | 9 + .../model/scrcpy/scrcpy_error.dart | 4 +- .../model/scrcpy/scrcpy_result.dart | 3 +- lib/application/scrcpy_bloc/scrcpy_bloc.dart | 8 +- lib/main.dart | 2 +- .../devices/bloc/devices_bloc.dart | 66 +++-- .../devices/bloc/devices_event.dart | 4 + .../devices/bloc/devices_state.dart | 18 +- lib/presentation/home/home_screen.dart | 258 ++++++------------ .../widget/adb_executable_setting.dart | 11 +- lib/service/adb_service.dart | 4 +- lib/service/navigation_service.dart | 155 +++++++++++ lib/service/running_process_manager.dart | 24 +- test/application/adb_result_parser_test.dart | 2 +- 18 files changed, 361 insertions(+), 230 deletions(-) create mode 100644 lib/application/model/process/stop_result.dart create mode 100644 lib/service/navigation_service.dart diff --git a/assets/i18n/en.json b/assets/i18n/en.json index a92461f..8c9703d 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -25,6 +25,9 @@ "start": "Start", "stop": "Stop", "error": { + "adb": { + "trackingFailed": "Failed to auto-refresh devices" + }, "scrcpy": { "notFound": { "title": "Scrcpy could not be found", diff --git a/lib/application/adb_result_parser.dart b/lib/application/adb_result_parser.dart index 5eb854f..fc22afe 100644 --- a/lib/application/adb_result_parser.dart +++ b/lib/application/adb_result_parser.dart @@ -27,18 +27,18 @@ class AdbResultParser { } } - Future parseInitResult(Future process) async { + Future parseTrackResult(Future Function() processSupplier) async { try { - final result = await process; - return AdbInitResult.right(result.exitCode); + final result = await processSupplier(); + return AdbTrackDevicesResult.right(result); } on ProcessException catch (e) { if (e.message.toLowerCase().contains("failed to find")) { - return AdbInitResult.left(AdbNotFoundError()); + return AdbTrackDevicesResult.left(AdbNotFoundError()); } else { - return AdbInitResult.left(UnknownAdbError(exception: e)); + return AdbTrackDevicesResult.left(UnknownAdbError(exception: e)); } } catch (e) { - return AdbInitResult.left(UnknownAdbError(exception: e)); + return AdbTrackDevicesResult.left(UnknownAdbError(exception: e)); } } diff --git a/lib/application/extension/scrcpy_error_extension.dart b/lib/application/extension/scrcpy_error_extension.dart index 05f5ac4..d68b667 100644 --- a/lib/application/extension/scrcpy_error_extension.dart +++ b/lib/application/extension/scrcpy_error_extension.dart @@ -8,8 +8,8 @@ extension ScrcpyErrorExtension on ScrcpyError { return (this as UnknownScrcpyError).exception?.toString() ?? 'Unknown error'; case const (ScrcpyNotFoundError): return 'Scrcpy not found'; - case const (ScrcpyKillError): - return "Failed to stop scrcpy: ${(this as ScrcpyKillError).message}"; + case const (ScrcpyStopError): + return "Failed to stop scrcpy: ${(this as ScrcpyStopError).exception}"; default: return "Unknown error"; } diff --git a/lib/application/model/adb/adb_result.dart b/lib/application/model/adb/adb_result.dart index 559f953..d4464d5 100644 --- a/lib/application/model/adb/adb_result.dart +++ b/lib/application/model/adb/adb_result.dart @@ -1,9 +1,11 @@ +import 'dart:io'; + import 'package:fpdart/fpdart.dart'; import 'package:scrcpy_buddy/application/model/adb/adb_connect_result_status.dart'; import 'package:scrcpy_buddy/application/model/adb/adb_device.dart'; import 'package:scrcpy_buddy/application/model/adb/adb_error.dart'; -typedef AdbInitResult = Either; +typedef AdbTrackDevicesResult = Either; typedef AdbDevicesResult = Either>; typedef AdbConnectResult = Either; typedef AdbVersionInfoResult = Either; diff --git a/lib/application/model/process/stop_result.dart b/lib/application/model/process/stop_result.dart new file mode 100644 index 0000000..c44a037 --- /dev/null +++ b/lib/application/model/process/stop_result.dart @@ -0,0 +1,9 @@ +import 'package:fpdart/fpdart.dart'; + +class ProcessStopError { + final Object? exception; + + ProcessStopError([this.exception]); +} + +typedef ProcessStopResult = Either; diff --git a/lib/application/model/scrcpy/scrcpy_error.dart b/lib/application/model/scrcpy/scrcpy_error.dart index 8060390..be6a0b2 100644 --- a/lib/application/model/scrcpy/scrcpy_error.dart +++ b/lib/application/model/scrcpy/scrcpy_error.dart @@ -10,10 +10,10 @@ class UnknownScrcpyError extends ScrcpyError { class ScrcpyNotFoundError extends ScrcpyError {} -class ScrcpyKillError extends ScrcpyError { +class ScrcpyStopError extends ScrcpyError { final Object? exception; - const ScrcpyKillError({this.exception}); + const ScrcpyStopError([this.exception]); } class ScrcpyListAppsError extends ScrcpyError { diff --git a/lib/application/model/scrcpy/scrcpy_result.dart b/lib/application/model/scrcpy/scrcpy_result.dart index aa58be8..63ec469 100644 --- a/lib/application/model/scrcpy/scrcpy_result.dart +++ b/lib/application/model/scrcpy/scrcpy_result.dart @@ -4,5 +4,4 @@ import 'package:fpdart/fpdart.dart'; import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_error.dart'; typedef ScrcpyResult = Either; -typedef ScrcpyStopResult = Either; -typedef ScrcpyListAppsResult = Either>; \ No newline at end of file +typedef ScrcpyListAppsResult = Either>; diff --git a/lib/application/scrcpy_bloc/scrcpy_bloc.dart b/lib/application/scrcpy_bloc/scrcpy_bloc.dart index e102d60..db2726a 100644 --- a/lib/application/scrcpy_bloc/scrcpy_bloc.dart +++ b/lib/application/scrcpy_bloc/scrcpy_bloc.dart @@ -83,7 +83,13 @@ class ScrcpyBloc extends Bloc { void _stop(StopScrcpyEvent event, _Emitter emit) { final result = _processManager.stop(event.deviceSerial); result.mapLeft( - (left) => emit(ScrcpyStopFailedState(deviceSerial: event.deviceSerial, error: left, devices: _runningDevices)), + (left) => emit( + ScrcpyStopFailedState( + deviceSerial: event.deviceSerial, + error: ScrcpyStopError(left.exception), + devices: _runningDevices, + ), + ), ); result.map((_) => emit(ScrcpyStopSuccessState(deviceSerial: event.deviceSerial, devices: _runningDevices))); } diff --git a/lib/main.dart b/lib/main.dart index 990f6ba..46b3c6e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,7 +91,7 @@ class _MyAppState extends State { providers: [ BlocProvider(create: (_) => ProfilesBloc(_settings, _objectBox.profileBox, _argsMap)), BlocProvider(create: (context) => ScrcpyBloc(context.read(), context.read(), context.read())), - BlocProvider(create: (context) => DevicesBloc(context.read(), _settings.adbExecutable)), + BlocProvider(create: (context) => DevicesBloc(context.read(), context.read(), _settings.adbExecutable)), ], child: child!, ); diff --git a/lib/presentation/devices/bloc/devices_bloc.dart b/lib/presentation/devices/bloc/devices_bloc.dart index 22d1aee..931face 100644 --- a/lib/presentation/devices/bloc/devices_bloc.dart +++ b/lib/presentation/devices/bloc/devices_bloc.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'dart:io'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:scrcpy_buddy/application/model/adb/adb_device.dart'; import 'package:scrcpy_buddy/application/model/adb/adb_error.dart'; import 'package:scrcpy_buddy/service/adb_service.dart'; +import 'package:scrcpy_buddy/service/running_process_manager.dart'; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; part 'devices_event.dart'; @@ -13,37 +16,60 @@ part 'devices_state.dart'; typedef _Emitter = Emitter; class DevicesBloc extends Bloc { - DevicesBloc(this._adbService, this._adbPath) : super(const DevicesInitial()) { + DevicesBloc(this._runningProcessManager, this._adbService, this._adbPath) : super(const DevicesInitial()) { + on((_, emit) => _startTracking(emit)); + on(_restartTracking); on(_onLoadDevices); on(_onToggleDeviceSelection); } - bool _initDone = false; + bool _isTracking = false; final AdbService _adbService; + final RunningProcessManager _runningProcessManager; final List _devices = []; final Set _selectedDeviceSerials = {}; final Preference _adbPath; - Future _onLoadDevices(LoadDevices event, _Emitter emit) async { + static const String adbTrackDevicesKey = "adb-track-devices"; + + @override + Future close() { + _stopTracking(); + return super.close(); + } + + void _stopTracking() { + _runningProcessManager + .stop(adbTrackDevicesKey) + .mapLeft((error) => debugPrint(error.exception?.toString() ?? 'Unknown stop error')) + .map((stopped) => kDebugMode ? debugPrint("Stopped: $stopped") : null); + + _isTracking = false; + } + + Future _restartTracking(RestartTracking _, _Emitter emit) async { + if (_isTracking) _stopTracking(); + await _startTracking(emit); + } + + Future _startTracking(_Emitter emit) async { + if (_isTracking) { + emit(InitDeviceTrackingSuccess()); + return; + } + final trackDevicesResult = await _adbService.startTrackDevices(_adbPath.getValue()); + trackDevicesResult.fold((error) => emit(InitDeviceTrackingFailed(adbError: error, message: error.toString())), ( + process, + ) { + _isTracking = true; + _runningProcessManager.add(adbTrackDevicesKey, process); + emit(InitDeviceTrackingSuccess()); + }); + } + + Future _onLoadDevices(_, _Emitter emit) async { try { emit(DevicesLoading()); - if (!_initDone) { - final initResult = await _adbService.init(_adbPath.getValue()); - if (initResult.isLeft()) { - final error = initResult.fold((l) => l, (r) => null); - emit( - DevicesUpdateError( - devices: _devices, - selectedDeviceSerials: _selectedDeviceSerials, - adbError: error, - message: error.toString(), - ), - ); - return; - } else { - _initDone = true; - } - } final devicesResult = await _adbService.devices(_adbPath.getValue()); devicesResult.fold( (error) => emit( diff --git a/lib/presentation/devices/bloc/devices_event.dart b/lib/presentation/devices/bloc/devices_event.dart index fd6ac7f..c9b8f16 100644 --- a/lib/presentation/devices/bloc/devices_event.dart +++ b/lib/presentation/devices/bloc/devices_event.dart @@ -7,6 +7,10 @@ sealed class DevicesEvent extends Equatable { List get props => []; } +final class InitDeviceTracking extends DevicesEvent {} + +final class RestartTracking extends DevicesEvent {} + final class LoadDevices extends DevicesEvent {} final class ToggleDeviceSelection extends DevicesEvent { diff --git a/lib/presentation/devices/bloc/devices_state.dart b/lib/presentation/devices/bloc/devices_state.dart index 75d9699..07efa83 100644 --- a/lib/presentation/devices/bloc/devices_state.dart +++ b/lib/presentation/devices/bloc/devices_state.dart @@ -11,6 +11,23 @@ final class DevicesInitial extends DevicesState { List get props => []; } +final class InitDeviceTrackingSuccess extends DevicesState { + const InitDeviceTrackingSuccess(); + + @override + List get props => []; +} + +final class InitDeviceTrackingFailed extends DevicesState { + final AdbError? adbError; + final String? message; + + const InitDeviceTrackingFailed({this.adbError, this.message}); + + @override + List get props => [adbError, message]; +} + final class DevicesLoading extends DevicesState { const DevicesLoading(); @@ -31,7 +48,6 @@ final class DevicesUpdateSuccess extends DevicesBaseUpdateState { @override List get props => [devices, selectedDeviceSerials]; - } final class DevicesUpdateError extends DevicesBaseUpdateState { diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 3d12640..9ff8d75 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,12 +1,13 @@ -import 'dart:io'; +import 'dart:async'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_error.dart'; import 'package:scrcpy_buddy/application/profiles_bloc/profiles_bloc.dart'; import 'package:scrcpy_buddy/application/scrcpy_bloc/scrcpy_bloc.dart'; +import 'package:scrcpy_buddy/presentation/devices/bloc/devices_bloc.dart'; import 'package:scrcpy_buddy/presentation/extension/context_extension.dart'; import 'package:scrcpy_buddy/presentation/extension/translation_extension.dart'; import 'package:scrcpy_buddy/presentation/home/console_section/console_section.dart'; @@ -16,6 +17,8 @@ import 'package:scrcpy_buddy/presentation/home/widgets/start_button.dart'; import 'package:scrcpy_buddy/presentation/home/widgets/stop_button.dart'; import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; import 'package:scrcpy_buddy/routes.dart'; +import 'package:scrcpy_buddy/service/navigation_service.dart'; +import 'package:scrcpy_buddy/service/running_process_manager.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; @@ -30,22 +33,11 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends AppModuleState with WindowListener, TrayListener { late final _profilesBloc = context.read(); + late final _devicesBloc = context.read(); + late final _runningProcessManager = context.read(); + final _navigationService = NavigationService(); - final _devicesKey = ValueKey('devices'); - - final _audioKey = ValueKey('scrcpyConfig.audio'); - final _cameraKey = ValueKey('scrcpyConfig.camera'); - final _controlKey = ValueKey('scrcpyConfig.control'); - final _deviceKey = ValueKey('scrcpyConfig.device'); - final _recordingKey = ValueKey('scrcpyConfig.recording'); - final _v4l2Key = ValueKey('scrcpyConfig.v4l2'); - final _videoKey = ValueKey('scrcpyConfig.video'); - final _virtualDisplayKey = ValueKey('scrcpyConfig.virtualDisplay'); - - final _windowKey = ValueKey('scrcpyConfig.window'); - - final _profilesKey = ValueKey('profiles'); - final _settingsKey = ValueKey('settings'); + StreamSubscription? _trackDevicesUpdateSubscription; @override String get module => 'home'; @@ -54,6 +46,8 @@ class _HomeScreenState extends AppModuleState with WindowListener, T void initState() { super.initState(); _profilesBloc.add(InitializeProfilesEvent()); + _devicesBloc.add(InitDeviceTracking()); + trayManager.addListener(this); windowManager.addListener(this); @@ -98,6 +92,7 @@ class _HomeScreenState extends AppModuleState with WindowListener, T void _exitApp() { trayManager.destroy(); windowManager.destroy(); + SystemNavigator.pop(animated: true); } @override @@ -109,131 +104,11 @@ class _HomeScreenState extends AppModuleState with WindowListener, T void dispose() { windowManager.removeListener(this); trayManager.removeListener(this); + _trackDevicesUpdateSubscription?.cancel(); super.dispose(); } - void _openRoute(String path) { - if (GoRouterState.of(context).uri.toString() != path) { - context.push(path); - } - } - - Text _buildPaneItemTitle(BuildContext context, ValueKey key) => - Text(context.translatedText(key: 'home.navigation.${key.value}')); - - List _getMainItems(BuildContext context) => [ - PaneItem( - key: _devicesKey, - icon: WindowsIcon(WindowsIcons.companion_app), - title: _buildPaneItemTitle(context, _devicesKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.devices), - ), - PaneItemHeader(header: _buildPaneItemTitle(context, ValueKey('scrcpyConfig.sectionTitle'))), - PaneItem( - key: _audioKey, - icon: WindowsIcon(WindowsIcons.audio), - title: _buildPaneItemTitle(context, _audioKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.audio), - ), - PaneItem( - key: _cameraKey, - icon: WindowsIcon(WindowsIcons.camera), - title: _buildPaneItemTitle(context, _cameraKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.camera), - ), - PaneItem( - key: _controlKey, - icon: WindowsIcon(WindowsIcons.keyboard_settings), - title: _buildPaneItemTitle(context, _controlKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.control), - ), - PaneItem( - key: _deviceKey, - icon: WindowsIcon(WindowsIcons.cell_phone), - title: _buildPaneItemTitle(context, _deviceKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.device), - ), - PaneItem( - key: _recordingKey, - icon: WindowsIcon(WindowsIcons.record), - title: _buildPaneItemTitle(context, _recordingKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.recording), - ), - if (Platform.isLinux) - PaneItem( - key: _v4l2Key, - icon: WindowsIcon(FluentIcons.funnel_chart), - title: _buildPaneItemTitle(context, _v4l2Key), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.v4l2), - ), - PaneItem( - key: _videoKey, - icon: WindowsIcon(WindowsIcons.video), - title: _buildPaneItemTitle(context, _videoKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.video), - ), - PaneItem( - key: _virtualDisplayKey, - icon: WindowsIcon(WindowsIcons.t_v_monitor), - title: _buildPaneItemTitle(context, _virtualDisplayKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.virtualDisplay), - ), - PaneItem( - key: _windowKey, - icon: WindowsIcon(WindowsIcons.hole_punch_portrait_top), - title: _buildPaneItemTitle(context, _windowKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.window), - ), - ]; - - List _getFooterItems(BuildContext context) => [ - PaneItemSeparator(), - PaneItem( - key: _profilesKey, - icon: const WindowsIcon(WindowsIcons.guest_user), - title: _buildPaneItemTitle(context, _profilesKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.profiles), - ), - PaneItem( - key: _settingsKey, - icon: const WindowsIcon(WindowsIcons.settings), - title: _buildPaneItemTitle(context, _settingsKey), - body: const SizedBox.shrink(), - onTap: () => _openRoute(AppRoute.settings), - ), - ]; - - int _calculateSelectedIndex(BuildContext context) { - bool isItemMatch(NavigationPaneItem item, String location) => "/${(item.key as ValueKey).value}" == location; - - int findIndex(List items, String location) => - items.where((item) => item.key != null).toList().indexWhere((item) => isItemMatch(item, location)); - - final location = GoRouterState.of(context).uri.toString(); - final mainItems = _getMainItems(context); - int indexOriginal = findIndex(mainItems, location); - - if (indexOriginal == -1) { - int indexFooter = findIndex(_getFooterItems(context), location); - if (indexFooter == -1) { - return 0; - } - return mainItems.where((element) => element.key != null).toList().length + indexFooter; - } else { - return indexOriginal; - } - } + void _onTrackDevicesUpdateReceived() => _devicesBloc.add(LoadDevices()); void _showUnexpectedStopInfoBar(ScrcpyStopSuccessState state) { showInfoBar( @@ -268,9 +143,13 @@ class _HomeScreenState extends AppModuleState with WindowListener, T @override Widget build(BuildContext context) { - final mainItems = _getMainItems(context); - final footerItems = _getFooterItems(context); - final selectedIndex = _calculateSelectedIndex(context); + final mainItems = _navigationService.getMainItems(context); + final footerItems = _navigationService.getFooterItems(context); + final selectedIndex = _navigationService.calculateSelectedIndex( + context, + mainItems: mainItems, + footerItems: footerItems, + ); return BlocListener( listener: (context, state) { if (state is ScrcpyStartFailedState) { @@ -287,47 +166,66 @@ class _HomeScreenState extends AppModuleState with WindowListener, T _showUnexpectedStopInfoBar(state); } }, - child: NavigationView( - paneBodyBuilder: (paneItem, _) => LayoutBuilder( - builder: (context, constraints) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: EdgeInsets.all(16), - child: Text( - translatedText(key: 'navigation.${(paneItem!.key! as ValueKey).value}'), - style: context.typography.title, + child: BlocConsumer( + listener: (context, state) { + if (state is InitDeviceTrackingSuccess) { + _trackDevicesUpdateSubscription?.cancel(); + _trackDevicesUpdateSubscription = _runningProcessManager + .getStdStream(DevicesBloc.adbTrackDevicesKey) + ?.listen((_) => _onTrackDevicesUpdateReceived()); + _devicesBloc.add(LoadDevices()); + } else if (state is InitDeviceTrackingFailed) { + showInfoBar( + title: translatedText(key: 'error.adb.trackingFailed'), + content: state.message ?? '', + severity: InfoBarSeverity.warning, + ); + } + }, + builder: (context, state) { + return NavigationView( + paneBodyBuilder: (paneItem, _) => LayoutBuilder( + builder: (context, constraints) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.all(16), + child: Text( + translatedText(key: 'navigation.${(paneItem!.key! as ValueKey).value}'), + style: context.typography.title, + ), + ), + Expanded(child: widget.child), + ConsoleSection(maxConsoleViewHeight: constraints.maxHeight * 0.7), + ], + ), + ), + pane: NavigationPane( + displayMode: PaneDisplayMode.compact, + items: mainItems, + selected: selectedIndex, + footerItems: footerItems, + ), + appBar: NavigationAppBar( + automaticallyImplyLeading: false, + title: Text(context.translatedText(key: 'appName'), style: typography.bodyStrong), + actions: Padding( + padding: const EdgeInsets.only(top: 12, right: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ProfileButton(), + const SizedBox(width: 8), + StartButton(), + const SizedBox(width: 8), + StopButton(), + ], ), ), - Expanded(child: widget.child), - ConsoleSection(maxConsoleViewHeight: constraints.maxHeight * 0.7), - ], - ), - ), - pane: NavigationPane( - displayMode: PaneDisplayMode.compact, - items: mainItems, - selected: selectedIndex, - footerItems: footerItems, - ), - appBar: NavigationAppBar( - automaticallyImplyLeading: false, - title: Text(context.translatedText(key: 'appName'), style: typography.bodyStrong), - actions: Padding( - padding: const EdgeInsets.only(top: 12, right: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ProfileButton(), - const SizedBox(width: 8), - StartButton(), - const SizedBox(width: 8), - StopButton(), - ], ), - ), - ), + ); + }, ), ); } diff --git a/lib/presentation/settings/widget/adb_executable_setting.dart b/lib/presentation/settings/widget/adb_executable_setting.dart index fe7f323..5ac762c 100644 --- a/lib/presentation/settings/widget/adb_executable_setting.dart +++ b/lib/presentation/settings/widget/adb_executable_setting.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:scrcpy_buddy/application/app_settings.dart'; import 'package:scrcpy_buddy/application/extension/adb_error_extension.dart'; +import 'package:scrcpy_buddy/presentation/devices/bloc/devices_bloc.dart'; import 'package:scrcpy_buddy/presentation/extension/translation_extension.dart'; import 'package:scrcpy_buddy/presentation/widgets/app_widgets.dart'; import 'package:scrcpy_buddy/service/adb_service.dart'; @@ -23,6 +24,7 @@ class _AdbExecutableSettingState extends AppModuleState { late final _executablePreference = context.read().adbExecutable; late final _adbService = context.read(); + late final _devicesBloc = context.read(); final _infoFlyoutController = FlyoutController(); final _textController = TextEditingController(); @@ -38,13 +40,13 @@ class _AdbExecutableSettingState extends AppModuleState { Future _validateAndSave(String? path) async { if (path == null) return; if (path.isEmpty) { - await _executablePreference.setValue(path); + _setExecutablePath(path); return; } setState(() => _isSaving = true); final file = File(path); if (await file.exists()) { - await _executablePreference.setValue(path); + _setExecutablePath(path); } else { showInfoBar( title: translatedText(key: 'invalidPath'), @@ -54,6 +56,11 @@ class _AdbExecutableSettingState extends AppModuleState { setState(() => _isSaving = false); } + Future _setExecutablePath(String path) async { + await _executablePreference.setValue(path); + _devicesBloc.add(RestartTracking()); + } + Future _checkVersionInfo() async { setState(() => _isCheckingVersionInfo = true); final result = await _adbService.getVersionInfo(_textController.text); diff --git a/lib/service/adb_service.dart b/lib/service/adb_service.dart index 29e0c0b..3cf546a 100644 --- a/lib/service/adb_service.dart +++ b/lib/service/adb_service.dart @@ -14,8 +14,8 @@ class AdbService { Future getVersionInfo(String path) => _resultParser.parseVersionInfoResult(_processManager.run([_getExecutable(path), '--version'])); - Future init(String path) => - _resultParser.parseInitResult(_processManager.run([_getExecutable(path), 'start-server'])); + Future startTrackDevices(String path) => + _resultParser.parseTrackResult(() => _processManager.start([_getExecutable(path), 'track-devices'])); Future devices(String path) => _resultParser.parseDevicesResult(_processManager.run([_getExecutable(path), 'devices', '-l'])); diff --git a/lib/service/navigation_service.dart b/lib/service/navigation_service.dart new file mode 100644 index 0000000..eadb646 --- /dev/null +++ b/lib/service/navigation_service.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:go_router/go_router.dart'; +import 'package:scrcpy_buddy/presentation/extension/translation_extension.dart'; +import 'package:scrcpy_buddy/routes.dart'; + +/// Encapsulates navigation pane configuration and route +/// index calculation for the home screen. +class NavigationService { + final _devicesKey = ValueKey('devices'); + + final _audioKey = ValueKey('scrcpyConfig.audio'); + final _cameraKey = ValueKey('scrcpyConfig.camera'); + final _controlKey = ValueKey('scrcpyConfig.control'); + final _deviceKey = ValueKey('scrcpyConfig.device'); + final _recordingKey = ValueKey('scrcpyConfig.recording'); + final _v4l2Key = ValueKey('scrcpyConfig.v4l2'); + final _videoKey = ValueKey('scrcpyConfig.video'); + final _virtualDisplayKey = ValueKey('scrcpyConfig.virtualDisplay'); + + final _windowKey = ValueKey('scrcpyConfig.window'); + + final _profilesKey = ValueKey('profiles'); + final _settingsKey = ValueKey('settings'); + + void _openRoute(BuildContext context, String path) { + if (GoRouterState.of(context).uri.toString() != path) { + context.push(path); + } + } + + Text _buildPaneItemTitle(BuildContext context, ValueKey key) => + Text(context.translatedText(key: 'home.navigation.${key.value}')); + + List getMainItems(BuildContext context) => [ + PaneItem( + key: _devicesKey, + icon: WindowsIcon(WindowsIcons.companion_app), + title: _buildPaneItemTitle(context, _devicesKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.devices), + ), + PaneItemHeader(header: _buildPaneItemTitle(context, ValueKey('scrcpyConfig.sectionTitle'))), + PaneItem( + key: _audioKey, + icon: WindowsIcon(WindowsIcons.audio), + title: _buildPaneItemTitle(context, _audioKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.audio), + ), + PaneItem( + key: _cameraKey, + icon: WindowsIcon(WindowsIcons.camera), + title: _buildPaneItemTitle(context, _cameraKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.camera), + ), + PaneItem( + key: _controlKey, + icon: WindowsIcon(WindowsIcons.keyboard_settings), + title: _buildPaneItemTitle(context, _controlKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.control), + ), + PaneItem( + key: _deviceKey, + icon: WindowsIcon(WindowsIcons.cell_phone), + title: _buildPaneItemTitle(context, _deviceKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.device), + ), + PaneItem( + key: _recordingKey, + icon: WindowsIcon(WindowsIcons.record), + title: _buildPaneItemTitle(context, _recordingKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.recording), + ), + if (Platform.isLinux) + PaneItem( + key: _v4l2Key, + icon: WindowsIcon(FluentIcons.funnel_chart), + title: _buildPaneItemTitle(context, _v4l2Key), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.v4l2), + ), + PaneItem( + key: _videoKey, + icon: WindowsIcon(WindowsIcons.video), + title: _buildPaneItemTitle(context, _videoKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.video), + ), + PaneItem( + key: _virtualDisplayKey, + icon: WindowsIcon(WindowsIcons.t_v_monitor), + title: _buildPaneItemTitle(context, _virtualDisplayKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.virtualDisplay), + ), + PaneItem( + key: _windowKey, + icon: WindowsIcon(WindowsIcons.hole_punch_portrait_top), + title: _buildPaneItemTitle(context, _windowKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.window), + ), + ]; + + List getFooterItems(BuildContext context) => [ + PaneItemSeparator(), + PaneItem( + key: _profilesKey, + icon: const WindowsIcon(WindowsIcons.guest_user), + title: _buildPaneItemTitle(context, _profilesKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.profiles), + ), + PaneItem( + key: _settingsKey, + icon: const WindowsIcon(WindowsIcons.settings), + title: _buildPaneItemTitle(context, _settingsKey), + body: const SizedBox.shrink(), + onTap: () => _openRoute(context, AppRoute.settings), + ), + ]; + + /// Calculates the selected navigation pane index based on + /// the current route. Accepts pre-built [mainItems] and + /// [footerItems] to avoid redundant list construction. + int calculateSelectedIndex( + BuildContext context, { + required List mainItems, + required List footerItems, + }) { + bool isItemMatch(NavigationPaneItem item, String location) => "/${(item.key as ValueKey).value}" == location; + + int findIndex(List items, String location) => + items.where((item) => item.key != null).toList().indexWhere((item) => isItemMatch(item, location)); + + final location = GoRouterState.of(context).uri.toString(); + int indexOriginal = findIndex(mainItems, location); + + if (indexOriginal == -1) { + int indexFooter = findIndex(footerItems, location); + if (indexFooter == -1) { + return 0; + } + return mainItems.where((element) => element.key != null).toList().length + indexFooter; + } else { + return indexOriginal; + } + } +} diff --git a/lib/service/running_process_manager.dart b/lib/service/running_process_manager.dart index 49442eb..62364e0 100644 --- a/lib/service/running_process_manager.dart +++ b/lib/service/running_process_manager.dart @@ -5,8 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:fpdart/fpdart.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_error.dart'; -import 'package:scrcpy_buddy/application/model/scrcpy/scrcpy_result.dart'; +import 'package:scrcpy_buddy/application/model/process/stop_result.dart'; import 'package:scrcpy_buddy/application/model/scrcpy/std_line.dart'; class RunningProcessManager { @@ -50,24 +49,31 @@ class RunningProcessManager { } void remove(String key) { - _processMap.remove(key); - _stdBehaviorSubjects[key]?.close(); - _stdBehaviorSubjects.remove(key); + try { + _processMap.remove(key); + _stdBehaviorSubjects[key]?.close(); + _stdBehaviorSubjects.remove(key); + } on StateError catch (e) { + // Ignore bad state exceptions + if (kDebugMode) { + debugPrint("StateError: $e"); + } + } } - ScrcpyStopResult stop(String key) { + ProcessStopResult stop(String key) { final process = _processMap[key]; try { if (process != null) { if (process.kill()) { remove(key); - return ScrcpyStopResult.right(true); + return ProcessStopResult.right(true); } else { - return ScrcpyStopResult.left(ScrcpyKillError()); + return ProcessStopResult.left(ProcessStopError()); } } } catch (e) { - return ScrcpyStopResult.left(UnknownScrcpyError(exception: e)); + return ProcessStopResult.left(ProcessStopError(e)); } return const Right(true); } diff --git a/test/application/adb_result_parser_test.dart b/test/application/adb_result_parser_test.dart index 927f207..127dc4d 100644 --- a/test/application/adb_result_parser_test.dart +++ b/test/application/adb_result_parser_test.dart @@ -46,7 +46,7 @@ void main() { ], ], (_, _ProcessResultSupplier processResultSupplier, Either expectedResult, expectedErrorType) async { - final result = await parser.parseInitResult(processResultSupplier()); + final result = await parser.parseTrackResult(processResultSupplier()); expect(result.isRight(), expectedResult.isRight()); if (expectedResult.isRight()) { expect(