diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e2828c5a..eaa47481 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..41bb3f8b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 13.0 + 15.5 diff --git a/ios/Podfile b/ios/Podfile index e72b0638..a154672b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '14.0' +platform :ios, '15.5' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ebe46af4..4804c94b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,8 +6,56 @@ PODS: - file_picker (0.0.1): - Flutter - Flutter (1.0.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (7.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 6.0.0) + - GoogleMLKit/MLKitCore (7.0.0): + - MLKitCommon (~> 12.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) - in_app_review (2.0.0): - Flutter + - MLImage (1.0.0-beta6) + - MLKitBarcodeScanning (6.0.0): + - MLKitCommon (~> 12.0) + - MLKitVision (~> 8.0) + - MLKitCommon (12.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (8.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta6) + - MLKitCommon (~> 12.0) + - mobile_scanner (6.0.2): + - Flutter + - GoogleMLKit/BarcodeScanning (~> 7.0.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - os_media_controls (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -15,6 +63,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - PromisesObjC (2.4.0) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -61,6 +110,7 @@ DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - os_media_controls (from `.symlinks/plugins/os_media_controls/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -74,6 +124,17 @@ DEPENDENCIES: SPEC REPOS: trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC - sqlite3 EXTERNAL SOURCES: @@ -87,6 +148,8 @@ EXTERNAL SOURCES: :path: Flutter in_app_review: :path: ".symlinks/plugins/in_app_review/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/ios" os_media_controls: :path: ".symlinks/plugins/os_media_controls/ios" package_info_plus: @@ -113,10 +176,22 @@ SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 + MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56 + MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2 + MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d + MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e + mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b @@ -126,6 +201,6 @@ SPEC CHECKSUMS: wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: faeab82d8b6e3c9d039ddf62b7666988a3dea540 +PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 65cff4e7..37b7b3cc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -214,6 +214,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */, + BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -380,6 +381,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -475,7 +493,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -496,7 +514,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Plezy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -609,7 +627,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -660,7 +678,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -683,7 +701,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Plezy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -710,7 +728,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Plezy; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index eae1b50b..5278ba84 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -40,7 +40,7 @@ NSLocalNetworkUsageDescription This app needs to connect to your Plex Media Server on your local network. NSCameraUsageDescription - Camera access is included by the WebRTC library used for Watch Together, but is not actively used by this app. + Camera is used to scan QR codes for Companion Remote pairing. UIApplicationSupportsIndirectInputEvents UIFileSharingEnabled diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index 8462b912..0d317a3a 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../utils/platform_detector.dart'; import '../services/gamepad_service.dart'; import 'dpad_navigator.dart'; +import '../services/companion_remote/companion_remote_receiver.dart'; /// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch). /// @@ -57,12 +58,16 @@ class _InputModeTrackerState extends State { // Register callback for gamepad input to switch to keyboard mode GamepadService.onGamepadInput = () => _setMode(InputMode.keyboard); + + // Register callback for companion remote input to switch to keyboard mode + CompanionRemoteReceiver.onRemoteInput = () => _setMode(InputMode.keyboard); } @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleKeyEvent); GamepadService.onGamepadInput = null; + CompanionRemoteReceiver.onRemoteInput = null; super.dispose(); } @@ -112,7 +117,15 @@ class _InputModeTrackerState extends State { onPointerDown: (_) => _setMode(InputMode.pointer), onPointerHover: (_) => _setMode(InputMode.pointer), behavior: HitTestBehavior.translucent, - child: _InputModeProvider(mode: _mode, child: widget.child), + child: MouseRegion( + cursor: _mode == InputMode.keyboard + ? SystemMouseCursors.none + : MouseCursor.defer, + child: IgnorePointer( + ignoring: _mode == InputMode.keyboard, + child: _InputModeProvider(mode: _mode, child: widget.child), + ), + ), ); } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 3f2d88ef..40632af4 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 5130 (570 per locale) +/// Strings: 5121 (569 per locale) /// -/// Built on 2026-02-09 at 17:54 UTC +/// Built on 2026-02-09 at 18:36 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/main.dart b/lib/main.dart index a21a439a..d6f9fed7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; import 'providers/offline_watch_provider.dart'; import 'providers/shader_provider.dart'; +import 'providers/companion_remote_provider.dart'; import 'watch_together/watch_together.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; @@ -307,6 +308,7 @@ class _MainAppState extends State with WidgetsBindingObserver { ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ChangeNotifierProvider(create: (context) => ShaderProvider()), + ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()), ], child: Consumer( builder: (context, themeProvider, child) { diff --git a/lib/mixins/refreshable.dart b/lib/mixins/refreshable.dart index 709d6039..9a4056ca 100644 --- a/lib/mixins/refreshable.dart +++ b/lib/mixins/refreshable.dart @@ -15,6 +15,7 @@ mixin FocusableTab { /// Mixin for screens with focusable search input mixin SearchInputFocusable { void focusSearchInput(); + void setSearchQuery(String query); } /// Mixin for screens that can load a specific library by key diff --git a/lib/models/companion_remote/recent_remote_session.dart b/lib/models/companion_remote/recent_remote_session.dart new file mode 100644 index 00000000..18a72877 --- /dev/null +++ b/lib/models/companion_remote/recent_remote_session.dart @@ -0,0 +1,52 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'recent_remote_session.g.dart'; + +/// Recent Companion Remote session for quick reconnection +@JsonSerializable() +class RecentRemoteSession { + final String sessionId; + final String pin; + final String deviceName; + final String platform; + final DateTime lastConnected; + final String? hostAddress; // Format: "ip:port" + + RecentRemoteSession({ + required this.sessionId, + required this.pin, + required this.deviceName, + required this.platform, + required this.lastConnected, + this.hostAddress, + }); + + factory RecentRemoteSession.fromJson(Map json) => _$RecentRemoteSessionFromJson(json); + + Map toJson() => _$RecentRemoteSessionToJson(this); + + /// Create from QR code data (format: "ip|port|sessionId|pin") + factory RecentRemoteSession.fromQrData(String qrData) { + final parts = qrData.split('|'); + if (parts.length < 4) { + throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin'); + } + + final ip = parts[0]; + final port = parts[1]; + final sessionId = parts[2]; + final pin = parts[3]; + + return RecentRemoteSession( + sessionId: sessionId, + pin: pin, + deviceName: 'Unknown Device', + platform: 'unknown', + lastConnected: DateTime.now(), + hostAddress: '$ip:$port', + ); + } + + @override + String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}'; +} diff --git a/lib/models/companion_remote/recent_remote_session.g.dart b/lib/models/companion_remote/recent_remote_session.g.dart new file mode 100644 index 00000000..4a97dfe5 --- /dev/null +++ b/lib/models/companion_remote/recent_remote_session.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recent_remote_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RecentRemoteSession _$RecentRemoteSessionFromJson(Map json) => RecentRemoteSession( + sessionId: json['sessionId'] as String, + pin: json['pin'] as String, + deviceName: json['deviceName'] as String, + platform: json['platform'] as String, + lastConnected: DateTime.parse(json['lastConnected'] as String), + hostAddress: json['hostAddress'] as String?, +); + +Map _$RecentRemoteSessionToJson(RecentRemoteSession instance) => { + 'sessionId': instance.sessionId, + 'pin': instance.pin, + 'deviceName': instance.deviceName, + 'platform': instance.platform, + 'lastConnected': instance.lastConnected.toIso8601String(), + 'hostAddress': instance.hostAddress, +}; diff --git a/lib/models/companion_remote/remote_command.dart b/lib/models/companion_remote/remote_command.dart new file mode 100644 index 00000000..d301da2c --- /dev/null +++ b/lib/models/companion_remote/remote_command.dart @@ -0,0 +1,26 @@ +import 'remote_command_type.dart'; + +class RemoteCommand { + final RemoteCommandType type; + final Map? data; + + const RemoteCommand({required this.type, this.data}); + + factory RemoteCommand.fromJson(Map json) { + final index = json['t'] as int; + return RemoteCommand( + type: index < RemoteCommandType.values.length ? RemoteCommandType.values[index] : RemoteCommandType.ping, + data: json['d'] as Map?, + ); + } + + Map toJson() { + return { + 't': type.index, + if (data != null) 'd': data, + }; + } + + @override + String toString() => 'RemoteCommand(${type.name}, data: $data)'; +} diff --git a/lib/models/companion_remote/remote_command_type.dart b/lib/models/companion_remote/remote_command_type.dart new file mode 100644 index 00000000..d7285ca1 --- /dev/null +++ b/lib/models/companion_remote/remote_command_type.dart @@ -0,0 +1,53 @@ +enum RemoteCommandType { + // Navigation + dpadUp, + dpadDown, + dpadLeft, + dpadRight, + select, + back, + contextMenu, + + // Playback + play, + pause, + playPause, + stop, + seekForward, + seekBackward, + nextTrack, + previousTrack, + skipIntro, + skipCredits, + + // Volume + volumeUp, + volumeDown, + volumeMute, + volumeSet, + + // Tab Navigation + tabNext, + tabPrevious, + tabDiscover, + tabLibraries, + tabSearch, + tabDownloads, + tabSettings, + + // Quick Actions + home, + search, + subtitles, + audioTracks, + qualitySettings, + fullscreen, + + // Session Management + ping, + pong, + deviceInfo, + disconnect, + ack, + syncState, +} diff --git a/lib/models/companion_remote/remote_session.dart b/lib/models/companion_remote/remote_session.dart new file mode 100644 index 00000000..e7b98c4b --- /dev/null +++ b/lib/models/companion_remote/remote_session.dart @@ -0,0 +1,108 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'remote_session.g.dart'; + +enum RemoteSessionRole { host, remote } + +enum RemoteSessionStatus { disconnected, connecting, connected, reconnecting, error } + +@JsonSerializable() +class RemoteDevice { + final String id; + final String name; + final String platform; + final DateTime connectedAt; + final Map capabilities; + + RemoteDevice({ + required this.id, + required this.name, + required this.platform, + DateTime? connectedAt, + Map? capabilities, + }) : connectedAt = connectedAt ?? DateTime.now(), + capabilities = capabilities ?? {}; + + factory RemoteDevice.fromJson(Map json) => _$RemoteDeviceFromJson(json); + + Map toJson() => _$RemoteDeviceToJson(this); + + RemoteDevice copyWith({ + String? id, + String? name, + String? platform, + DateTime? connectedAt, + Map? capabilities, + }) { + return RemoteDevice( + id: id ?? this.id, + name: name ?? this.name, + platform: platform ?? this.platform, + connectedAt: connectedAt ?? this.connectedAt, + capabilities: capabilities ?? this.capabilities, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is RemoteDevice && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +@JsonSerializable() +class RemoteSession { + final String sessionId; + final String pin; + @JsonKey(unknownEnumValue: RemoteSessionRole.remote) + final RemoteSessionRole role; + @JsonKey(unknownEnumValue: RemoteSessionStatus.disconnected) + final RemoteSessionStatus status; + final RemoteDevice? connectedDevice; + final DateTime createdAt; + final String? errorMessage; + + RemoteSession({ + required this.sessionId, + required this.pin, + required this.role, + this.status = RemoteSessionStatus.disconnected, + this.connectedDevice, + DateTime? createdAt, + this.errorMessage, + }) : createdAt = createdAt ?? DateTime.now(); + + bool get isConnected => status == RemoteSessionStatus.connected; + bool get isHost => role == RemoteSessionRole.host; + bool get isRemote => role == RemoteSessionRole.remote; + + factory RemoteSession.fromJson(Map json) => _$RemoteSessionFromJson(json); + + Map toJson() => _$RemoteSessionToJson(this); + + RemoteSession copyWith({ + String? sessionId, + String? pin, + RemoteSessionRole? role, + RemoteSessionStatus? status, + RemoteDevice? connectedDevice, + bool clearConnectedDevice = false, + DateTime? createdAt, + String? errorMessage, + bool clearErrorMessage = false, + }) { + return RemoteSession( + sessionId: sessionId ?? this.sessionId, + pin: pin ?? this.pin, + role: role ?? this.role, + status: status ?? this.status, + connectedDevice: clearConnectedDevice ? null : (connectedDevice ?? this.connectedDevice), + createdAt: createdAt ?? this.createdAt, + errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage), + ); + } +} diff --git a/lib/models/companion_remote/remote_session.g.dart b/lib/models/companion_remote/remote_session.g.dart new file mode 100644 index 00000000..00ae33c2 --- /dev/null +++ b/lib/models/companion_remote/remote_session.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'remote_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RemoteDevice _$RemoteDeviceFromJson(Map json) => RemoteDevice( + id: json['id'] as String, + name: json['name'] as String, + platform: json['platform'] as String, + connectedAt: json['connectedAt'] == null ? null : DateTime.parse(json['connectedAt'] as String), + capabilities: (json['capabilities'] as Map?)?.map((k, e) => MapEntry(k, e as bool)), +); + +Map _$RemoteDeviceToJson(RemoteDevice instance) => { + 'id': instance.id, + 'name': instance.name, + 'platform': instance.platform, + 'connectedAt': instance.connectedAt.toIso8601String(), + 'capabilities': instance.capabilities, +}; + +RemoteSession _$RemoteSessionFromJson(Map json) => RemoteSession( + sessionId: json['sessionId'] as String, + pin: json['pin'] as String, + role: $enumDecode(_$RemoteSessionRoleEnumMap, json['role'], unknownValue: RemoteSessionRole.remote), + status: + $enumDecodeNullable( + _$RemoteSessionStatusEnumMap, + json['status'], + unknownValue: RemoteSessionStatus.disconnected, + ) ?? + RemoteSessionStatus.disconnected, + connectedDevice: json['connectedDevice'] == null + ? null + : RemoteDevice.fromJson(json['connectedDevice'] as Map), + createdAt: json['createdAt'] == null ? null : DateTime.parse(json['createdAt'] as String), + errorMessage: json['errorMessage'] as String?, +); + +Map _$RemoteSessionToJson(RemoteSession instance) => { + 'sessionId': instance.sessionId, + 'pin': instance.pin, + 'role': _$RemoteSessionRoleEnumMap[instance.role]!, + 'status': _$RemoteSessionStatusEnumMap[instance.status]!, + 'connectedDevice': instance.connectedDevice, + 'createdAt': instance.createdAt.toIso8601String(), + 'errorMessage': instance.errorMessage, +}; + +const _$RemoteSessionRoleEnumMap = {RemoteSessionRole.host: 'host', RemoteSessionRole.remote: 'remote'}; + +const _$RemoteSessionStatusEnumMap = { + RemoteSessionStatus.disconnected: 'disconnected', + RemoteSessionStatus.connecting: 'connecting', + RemoteSessionStatus.connected: 'connected', + RemoteSessionStatus.reconnecting: 'reconnecting', + RemoteSessionStatus.error: 'error', +}; diff --git a/lib/models/companion_remote/trusted_device.dart b/lib/models/companion_remote/trusted_device.dart new file mode 100644 index 00000000..e367c852 --- /dev/null +++ b/lib/models/companion_remote/trusted_device.dart @@ -0,0 +1,54 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'trusted_device.g.dart'; + +@JsonSerializable() +class TrustedDevice { + final String peerId; + final String deviceName; + final String platform; + final DateTime firstConnected; + final DateTime lastConnected; + final bool isApproved; + + TrustedDevice({ + required this.peerId, + required this.deviceName, + required this.platform, + DateTime? firstConnected, + DateTime? lastConnected, + this.isApproved = false, + }) : firstConnected = firstConnected ?? DateTime.now(), + lastConnected = lastConnected ?? DateTime.now(); + + factory TrustedDevice.fromJson(Map json) => _$TrustedDeviceFromJson(json); + + Map toJson() => _$TrustedDeviceToJson(this); + + TrustedDevice copyWith({ + String? peerId, + String? deviceName, + String? platform, + DateTime? firstConnected, + DateTime? lastConnected, + bool? isApproved, + }) { + return TrustedDevice( + peerId: peerId ?? this.peerId, + deviceName: deviceName ?? this.deviceName, + platform: platform ?? this.platform, + firstConnected: firstConnected ?? this.firstConnected, + lastConnected: lastConnected ?? this.lastConnected, + isApproved: isApproved ?? this.isApproved, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TrustedDevice && other.peerId == peerId; + } + + @override + int get hashCode => peerId.hashCode; +} diff --git a/lib/models/companion_remote/trusted_device.g.dart b/lib/models/companion_remote/trusted_device.g.dart new file mode 100644 index 00000000..38a195dc --- /dev/null +++ b/lib/models/companion_remote/trusted_device.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trusted_device.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TrustedDevice _$TrustedDeviceFromJson(Map json) => TrustedDevice( + peerId: json['peerId'] as String, + deviceName: json['deviceName'] as String, + platform: json['platform'] as String, + firstConnected: json['firstConnected'] == null ? null : DateTime.parse(json['firstConnected'] as String), + lastConnected: json['lastConnected'] == null ? null : DateTime.parse(json['lastConnected'] as String), + isApproved: json['isApproved'] as bool? ?? false, +); + +Map _$TrustedDeviceToJson(TrustedDevice instance) => { + 'peerId': instance.peerId, + 'deviceName': instance.deviceName, + 'platform': instance.platform, + 'firstConnected': instance.firstConnected.toIso8601String(), + 'lastConnected': instance.lastConnected.toIso8601String(), + 'isApproved': instance.isApproved, +}; diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart new file mode 100644 index 00000000..8d2f093e --- /dev/null +++ b/lib/providers/companion_remote_provider.dart @@ -0,0 +1,556 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:device_info_plus/device_info_plus.dart'; + +import '../models/companion_remote/remote_command.dart'; +import '../models/companion_remote/remote_command_type.dart'; +import '../models/companion_remote/remote_session.dart'; +import '../models/companion_remote/trusted_device.dart'; +import '../services/companion_remote/companion_remote_peer_service.dart'; +import '../models/companion_remote/recent_remote_session.dart'; +import '../services/companion_remote/companion_remote_discovery_service.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; + +typedef CommandReceivedCallback = void Function(RemoteCommand command); +typedef DeviceApprovalCallback = Future Function(RemoteDevice device); + +class CompanionRemoteProvider with ChangeNotifier { + RemoteSession? _session; + CompanionRemotePeerService? _peerService; + CompanionRemoteDiscoveryService? _discoveryService; + String _deviceName = 'Unknown Device'; + String _platform = 'unknown'; + final List _trustedDevices = []; + final List _recentSessions = []; + bool _isPlayerActive = false; + + static const String _storageKey = 'companion_remote_trusted_devices'; + static const String _lastDeviceKey = 'companion_remote_last_device'; + static const int _maxReconnectAttempts = 5; + + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + bool _intentionalDisconnect = false; + String? _lastSessionId; + String? _lastPin; + String? _lastHostAddress; + + int get reconnectAttempts => _reconnectAttempts; + + StreamSubscription? _commandSubscription; + StreamSubscription? _deviceConnectedSubscription; + StreamSubscription? _deviceDisconnectedSubscription; + StreamSubscription? _errorSubscription; + StreamSubscription? _statusSubscription; + StreamSubscription>? _recentSessionsSubscription; + + CommandReceivedCallback? onCommandReceived; + DeviceApprovalCallback? onDeviceApprovalRequired; + + bool get isInSession => _session != null && _session!.status != RemoteSessionStatus.disconnected; + bool get isHost => _session?.isHost ?? false; + bool get isRemote => _session?.isRemote ?? false; + bool get isConnected => _session?.isConnected ?? false; + RemoteSession? get session => _session; + RemoteSessionStatus get status => _session?.status ?? RemoteSessionStatus.disconnected; + String? get sessionId => _session?.sessionId; + String? get pin => _session?.pin; + RemoteDevice? get connectedDevice => _session?.connectedDevice; + List get trustedDevices => List.unmodifiable(_trustedDevices); + List get recentSessions => List.unmodifiable(_recentSessions); + bool get isPlayerActive => _isPlayerActive; + + CompanionRemoteProvider() { + _initializeDeviceInfo(); + _loadTrustedDevices(); + } + + Future _initializeDeviceInfo() async { + final deviceInfo = DeviceInfoPlugin(); + + try { + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + _deviceName = '${androidInfo.brand} ${androidInfo.model}'; + _platform = 'Android'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + _deviceName = iosInfo.name; + _platform = 'iOS'; + } else if (Platform.isMacOS) { + final macInfo = await deviceInfo.macOsInfo; + _deviceName = macInfo.computerName; + _platform = 'macOS'; + } else if (Platform.isWindows) { + final windowsInfo = await deviceInfo.windowsInfo; + _deviceName = windowsInfo.computerName; + _platform = 'Windows'; + } else if (Platform.isLinux) { + final linuxInfo = await deviceInfo.linuxInfo; + _deviceName = linuxInfo.name; + _platform = 'Linux'; + } + } catch (e) { + appLogger.e('CompanionRemote: Failed to get device info', error: e); + _deviceName = 'Unknown Device'; + _platform = Platform.operatingSystem; + } + + notifyListeners(); + } + + void _setupPeerServiceListeners() { + _commandSubscription = _peerService!.onCommandReceived.listen( + (command) { + appLogger.d('CompanionRemote: Command received: ${command.type}'); + + if (command.type == RemoteCommandType.deviceInfo) { + _handleDeviceInfo(command); + } else if (command.type == RemoteCommandType.syncState) { + _handleSyncState(command); + } else if (command.type == RemoteCommandType.ping || + command.type == RemoteCommandType.pong || + command.type == RemoteCommandType.ack) { + // Don't call callback for these + } else { + onCommandReceived?.call(command); + } + }, + onError: (error) { + appLogger.e('CompanionRemote: Stream error', error: error); + }, + ); + + _deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) async { + appLogger.d('CompanionRemote: Device connected: ${device.name}'); + _session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device); + notifyListeners(); + + await addTrustedDevice(device, requireApproval: isHost); + }); + + _deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) { + appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)'); + if (_intentionalDisconnect) { + _session = _session?.copyWith( + status: RemoteSessionStatus.disconnected, + clearConnectedDevice: true, + ); + notifyListeners(); + } else if (isHost) { + // Host keeps the server running — the client will reconnect on its own + _session = _session?.copyWith( + status: RemoteSessionStatus.reconnecting, + clearConnectedDevice: true, + clearErrorMessage: true, + ); + notifyListeners(); + appLogger.d('CompanionRemote: Host waiting for client to reconnect'); + } else { + _session = _session?.copyWith(status: RemoteSessionStatus.reconnecting); + notifyListeners(); + _scheduleReconnect(); + } + }); + + _errorSubscription = _peerService!.onError.listen((error) { + appLogger.e('CompanionRemote: Error: ${error.message}'); + _session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: error.message); + notifyListeners(); + }); + + _statusSubscription = _peerService!.onConnectionStateChanged.listen((status) { + appLogger.d('CompanionRemote: Status changed: $status'); + _session = _session?.copyWith(status: status); + notifyListeners(); + }); + } + + Future _handleDeviceInfo(RemoteCommand command) async { + if (command.data != null) { + final id = command.data!['id'] as String? ?? 'unknown'; + final name = command.data!['name'] as String? ?? 'Unknown Device'; + final platform = command.data!['platform'] as String? ?? 'unknown'; + final role = command.data!['role'] as String?; + + appLogger.d('CompanionRemote: Device info - name: $name, platform: $platform, role: $role'); + + final device = RemoteDevice(id: id, name: name, platform: platform); + + _session = _session?.copyWith(connectedDevice: device); + notifyListeners(); + + // Save to recent sessions now that we have the remote device's real identity + await _addToRecentSessions(); + } + } + + void _handleSyncState(RemoteCommand command) { + final playerActive = command.data?['playerActive'] as bool? ?? false; + if (_isPlayerActive != playerActive) { + _isPlayerActive = playerActive; + notifyListeners(); + } + } + + void _cleanupSubscriptions() { + _commandSubscription?.cancel(); + _commandSubscription = null; + _deviceConnectedSubscription?.cancel(); + _deviceConnectedSubscription = null; + _deviceDisconnectedSubscription?.cancel(); + _deviceDisconnectedSubscription = null; + _errorSubscription?.cancel(); + _errorSubscription = null; + _statusSubscription?.cancel(); + _statusSubscription = null; + } + + Future<({String sessionId, String pin, String address})> createSession() async { + await leaveSession(); + + appLogger.d('CompanionRemote: Creating session as host'); + + _peerService = CompanionRemotePeerService(); + _setupPeerServiceListeners(); + + try { + final result = await _peerService!.createSession(_deviceName, _platform); + + _session = RemoteSession( + sessionId: result.sessionId, + pin: result.pin, + role: RemoteSessionRole.host, + status: RemoteSessionStatus.connected, + ); + + notifyListeners(); + appLogger.d( + 'CompanionRemote: Session created - ID: ${result.sessionId}, PIN: ${result.pin}, Address: ${result.address}', + ); + + return result; + } catch (e) { + appLogger.e('CompanionRemote: Failed to create session', error: e); + _session = RemoteSession( + sessionId: '', + pin: '', + role: RemoteSessionRole.host, + status: RemoteSessionStatus.error, + errorMessage: e.toString(), + ); + notifyListeners(); + rethrow; + } + } + + Future joinSession(String sessionId, String pin, String hostAddress) async { + await leaveSession(); + + _lastSessionId = sessionId; + _lastPin = pin; + _lastHostAddress = hostAddress; + + appLogger.d('CompanionRemote: Joining session - ID: $sessionId, Host: $hostAddress'); + + _peerService = CompanionRemotePeerService(); + _setupPeerServiceListeners(); + + _session = RemoteSession( + sessionId: sessionId, + pin: pin, + role: RemoteSessionRole.remote, + status: RemoteSessionStatus.connecting, + ); + notifyListeners(); + + try { + await _peerService!.joinSession(sessionId, pin, _deviceName, _platform, hostAddress); + + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + notifyListeners(); + appLogger.d('CompanionRemote: Successfully joined session'); + } catch (e) { + appLogger.e('CompanionRemote: Failed to join session', error: e); + _session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: e.toString()); + notifyListeners(); + rethrow; + } + } + + void sendCommand(RemoteCommandType type, {Map? data}) { + if (_peerService == null || !isConnected) { + appLogger.w('CompanionRemote: Cannot send command - not connected'); + return; + } + + appLogger.d('CompanionRemote: Sending command $type'); + _peerService!.sendCommand(RemoteCommand(type: type, data: data)); + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + appLogger.w('CompanionRemote: Max reconnect attempts reached'); + _session = _session?.copyWith( + status: RemoteSessionStatus.error, + errorMessage: 'Connection lost after $_maxReconnectAttempts attempts', + ); + _reconnectAttempts = 0; + notifyListeners(); + return; + } + + final delay = Duration(seconds: 1 << _reconnectAttempts); // 1s, 2s, 4s, 8s, 16s + _reconnectAttempts++; + appLogger.d('CompanionRemote: Reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s'); + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(delay, _attemptReconnect); + } + + Future _attemptReconnect() async { + if (_lastSessionId == null || _lastPin == null || _lastHostAddress == null) { + appLogger.w('CompanionRemote: No stored credentials for reconnect'); + _session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: 'Connection lost'); + notifyListeners(); + return; + } + + try { + appLogger.d('CompanionRemote: Attempting reconnect...'); + // Clean up old peer service without triggering intentional disconnect + _cleanupSubscriptions(); + await _peerService?.disconnect(); + + _peerService = CompanionRemotePeerService(); + _setupPeerServiceListeners(); + + await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!); + + _session = _session?.copyWith(status: RemoteSessionStatus.connected, clearErrorMessage: true); + _reconnectAttempts = 0; + notifyListeners(); + appLogger.d('CompanionRemote: Reconnected successfully'); + } catch (e) { + appLogger.e('CompanionRemote: Reconnect failed', error: e); + if (_session?.status == RemoteSessionStatus.reconnecting) { + _scheduleReconnect(); + } + } + } + + /// Immediately retry reconnection, skipping the backoff wait + void retryReconnectNow() { + _reconnectTimer?.cancel(); + _reconnectAttempts = 0; + _attemptReconnect(); + } + + /// Cancel ongoing reconnection attempts + void cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectAttempts = 0; + _session = _session?.copyWith( + status: RemoteSessionStatus.disconnected, + clearConnectedDevice: true, + ); + notifyListeners(); + } + + Future leaveSession() async { + _intentionalDisconnect = true; + _reconnectTimer?.cancel(); + _reconnectAttempts = 0; + + if (_peerService != null) { + appLogger.d('CompanionRemote: Leaving session'); + await _peerService!.disconnect(); + _peerService = null; + } + + _cleanupSubscriptions(); + + _session = null; + _isPlayerActive = false; + _intentionalDisconnect = false; + notifyListeners(); + } + + Future _loadTrustedDevices() async { + try { + final storage = await StorageService.getInstance(); + final json = storage.prefs.getString(_storageKey); + if (json != null) { + final List list = jsonDecode(json); + _trustedDevices.clear(); + _trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map))); + appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices'); + } + } catch (e) { + appLogger.e('CompanionRemote: Failed to load trusted devices', error: e); + } + } + + Future _saveTrustedDevices() async { + try { + final storage = await StorageService.getInstance(); + final json = jsonEncode(_trustedDevices.map((e) => e.toJson()).toList()); + await storage.prefs.setString(_storageKey, json); + appLogger.d('CompanionRemote: Saved ${_trustedDevices.length} trusted devices'); + } catch (e) { + appLogger.e('CompanionRemote: Failed to save trusted devices', error: e); + } + } + + bool isDeviceTrusted(String peerId) { + return _trustedDevices.any((d) => d.peerId == peerId && d.isApproved); + } + + Future addTrustedDevice(RemoteDevice device, {bool requireApproval = true}) async { + final existing = _trustedDevices.where((d) => d.peerId == device.id).firstOrNull; + + if (existing != null) { + final updated = existing.copyWith( + deviceName: device.name, + platform: device.platform, + lastConnected: DateTime.now(), + isApproved: !requireApproval || existing.isApproved, + ); + _trustedDevices.remove(existing); + _trustedDevices.add(updated); + } else { + bool approved = !requireApproval; + + if (requireApproval && onDeviceApprovalRequired != null) { + approved = await onDeviceApprovalRequired!(device); + } + + _trustedDevices.add( + TrustedDevice(peerId: device.id, deviceName: device.name, platform: device.platform, isApproved: approved), + ); + } + + await _saveTrustedDevices(); + + if (isRemote) { + final storage = await StorageService.getInstance(); + await storage.prefs.setString(_lastDeviceKey, device.id); + } + + notifyListeners(); + } + + Future removeTrustedDevice(String peerId) async { + _trustedDevices.removeWhere((d) => d.peerId == peerId); + await _saveTrustedDevices(); + notifyListeners(); + } + + Future approveTrustedDevice(String peerId) async { + final device = _trustedDevices.where((d) => d.peerId == peerId).firstOrNull; + if (device != null) { + final updated = device.copyWith(isApproved: true); + _trustedDevices.remove(device); + _trustedDevices.add(updated); + await _saveTrustedDevices(); + notifyListeners(); + } + } + + Future getLastConnectedDevicePeerId() async { + final storage = await StorageService.getInstance(); + return storage.prefs.getString(_lastDeviceKey); + } + + /// Load recent sessions + Future loadRecentSessions() async { + try { + // Dispose previous discovery service and subscription to avoid leaks + _recentSessionsSubscription?.cancel(); + _recentSessionsSubscription = null; + _discoveryService?.dispose(); + + _discoveryService = CompanionRemoteDiscoveryService(); + + // Listen for recent sessions updates + _recentSessionsSubscription = _discoveryService!.recentSessions.listen((sessions) { + _recentSessions.clear(); + _recentSessions.addAll(sessions); + notifyListeners(); + }); + + // Initial load happens in constructor, just notify + _recentSessions.clear(); + _recentSessions.addAll(_discoveryService!.currentSessions); + notifyListeners(); + + appLogger.d('CompanionRemote: Loaded ${_recentSessions.length} recent sessions'); + } catch (e) { + appLogger.e('CompanionRemote: Failed to load recent sessions', error: e); + } + } + + /// Add current session to recent list (called after successful connection) + Future _addToRecentSessions() async { + if (_session == null || _session!.sessionId.isEmpty) return; + + // For mobile (remote role), save the connected desktop device + // For desktop (host role), this doesn't really apply but save connected mobile device + final deviceToSave = _session!.connectedDevice; + if (deviceToSave == null) { + appLogger.w('CompanionRemote: No connected device to save to recent sessions'); + return; + } + + final recentSession = RecentRemoteSession( + sessionId: _session!.sessionId, + pin: _session!.pin, + deviceName: deviceToSave.name, + platform: deviceToSave.platform, + lastConnected: DateTime.now(), + hostAddress: _peerService?.hostAddress, + ); + + if (_discoveryService != null) { + await _discoveryService!.addRecentSession(recentSession); + } + } + + /// Connect to a recent session + Future connectToRecentSession(RecentRemoteSession session) async { + if (session.hostAddress == null) { + throw const RemotePeerError( + type: RemotePeerErrorType.invalidSession, + message: 'No host address available for this session. Please scan a new QR code.', + ); + } + await joinSession(session.sessionId, session.pin, session.hostAddress!); + } + + /// Remove a recent session + Future removeRecentSession(String sessionId) async { + if (_discoveryService != null) { + await _discoveryService!.removeRecentSession(sessionId); + } + } + + /// Clear all recent sessions + Future clearRecentSessions() async { + if (_discoveryService != null) { + await _discoveryService!.clearRecentSessions(); + } + } + + @override + void dispose() { + _reconnectTimer?.cancel(); + leaveSession(); + _recentSessionsSubscription?.cancel(); + _discoveryService?.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart new file mode 100644 index 00000000..1dd780a2 --- /dev/null +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -0,0 +1,669 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../models/companion_remote/remote_command_type.dart'; +import '../../models/companion_remote/remote_session.dart'; +import '../../providers/companion_remote_provider.dart'; +import '../../utils/platform_detector.dart'; +import '../../utils/app_logger.dart'; +import 'pairing_screen.dart'; + +class MobileRemoteScreen extends StatefulWidget { + const MobileRemoteScreen({super.key}); + + @override + State createState() => _MobileRemoteScreenState(); +} + +class _MobileRemoteScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Companion Remote'), + actions: [ + Consumer( + builder: (context, provider, child) { + if (provider.isConnected) { + return IconButton( + icon: const Icon(Icons.link_off), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Disconnect'), + content: const Text('Do you want to disconnect from the remote session?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Disconnect')), + ], + ), + ); + + if (confirmed == true && context.mounted) { + await context.read().leaveSession(); + } + }, + tooltip: 'Disconnect', + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + body: Consumer( + builder: (context, provider, child) { + if (provider.status == RemoteSessionStatus.reconnecting) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text('Reconnecting...', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text( + 'Attempt ${provider.reconnectAttempts} of 5', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton(onPressed: () => provider.cancelReconnect(), child: const Text('Cancel')), + const SizedBox(width: 16), + FilledButton(onPressed: () => provider.retryReconnectNow(), child: const Text('Retry Now')), + ], + ), + ], + ), + ); + } + + if (!provider.isConnected) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.phonelink_off, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + provider.status == RemoteSessionStatus.error + ? provider.session?.errorMessage ?? 'Connection error' + : 'Not connected', + style: const TextStyle(fontSize: 20, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const PairingScreen())); + }, + icon: const Icon(Icons.link), + label: const Text('Connect to Device'), + ), + ], + ), + ); + } + + return const _RemoteControlLayout(); + }, + ), + ); + } +} + +class _RemoteControlLayout extends StatelessWidget { + const _RemoteControlLayout(); + + @override + Widget build(BuildContext context) { + if (PlatformDetector.isDesktop(context)) { + return Center( + child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 400), child: const _RemoteControlContent()), + ); + } + + return const _RemoteControlContent(); + } +} + +class _RemoteControlContent extends StatefulWidget { + const _RemoteControlContent(); + + @override + State<_RemoteControlContent> createState() => _RemoteControlContentState(); +} + +class _RemoteControlContentState extends State<_RemoteControlContent> { + int _selectedTab = 0; + + void _showSearchSheet({bool switchToSearchTab = false}) { + if (switchToSearchTab) { + _sendCommand(RemoteCommandType.tabSearch); + } + final provider = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _SearchBottomSheet(provider: provider), + ); + } + + void _sendCommand(RemoteCommandType type) { + HapticFeedback.lightImpact(); + appLogger.d('MobileRemoteScreen: Sending command: $type'); + final provider = context.read(); + provider.sendCommand(type); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Consumer( + builder: (context, provider, child) { + final device = provider.connectedDevice; + if (device == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(16), + color: Theme.of(context).colorScheme.primaryContainer, + child: Row( + children: [ + Icon(Icons.computer, color: Theme.of(context).colorScheme.onPrimaryContainer), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + Text( + device.platform, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + ], + ), + ), + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle), + ), + ], + ), + ); + }, + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: 0, label: Text('Remote'), icon: Icon(Icons.navigation)), + ButtonSegment(value: 1, label: Text('Play'), icon: Icon(Icons.play_arrow)), + ButtonSegment(value: 2, label: Text('More'), icon: Icon(Icons.flash_on)), + ], + selected: {_selectedTab}, + onSelectionChanged: (Set selection) { + setState(() { + _selectedTab = selection.first; + }); + }, + ), + const SizedBox(height: 24), + if (_selectedTab == 0) _buildNavigationTab(), + if (_selectedTab == 1) _buildPlaybackTab(), + if (_selectedTab == 2) _buildQuickActionsTab(), + ], + ), + ), + ), + ], + ); + } + + Widget _buildNavigationTab() { + final isPlayerActive = context.watch().isPlayerActive; + + return Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _RemoteButton(icon: Icons.home, label: 'Home', onPressed: () => _sendCommand(RemoteCommandType.home)), + _RemoteButton(icon: Icons.arrow_back, label: 'Back', onPressed: () => _sendCommand(RemoteCommandType.back)), + _RemoteButton( + icon: Icons.menu, + label: 'Menu', + onPressed: () => _sendCommand(RemoteCommandType.contextMenu), + ), + ], + ), + const SizedBox(height: 32), + Center(child: _DPad(onCommand: _sendCommand)), + if (!isPlayerActive) ...[ + const SizedBox(height: 32), + Text('Tab Navigation', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + _RemoteChip( + icon: Icons.explore, + label: 'Discover', + onPressed: () => _sendCommand(RemoteCommandType.tabDiscover), + ), + _RemoteChip( + icon: Icons.video_library, + label: 'Libraries', + onPressed: () => _sendCommand(RemoteCommandType.tabLibraries), + ), + _RemoteChip( + icon: Icons.search, + label: 'Search', + onPressed: () => _showSearchSheet(switchToSearchTab: true), + ), + _RemoteChip( + icon: Icons.download, + label: 'Downloads', + onPressed: () => _sendCommand(RemoteCommandType.tabDownloads), + ), + _RemoteChip( + icon: Icons.settings, + label: 'Settings', + onPressed: () => _sendCommand(RemoteCommandType.tabSettings), + ), + ], + ), + ], + ], + ); + } + + Widget _buildPlaybackTab() { + return Column( + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _RemoteButton( + icon: Icons.skip_previous, + label: 'Previous', + onPressed: () => _sendCommand(RemoteCommandType.previousTrack), + ), + const SizedBox(width: 16), + _RemoteButton( + icon: Icons.play_arrow, + label: 'Play/Pause', + size: 64, + iconSize: 36, + onPressed: () => _sendCommand(RemoteCommandType.playPause), + ), + const SizedBox(width: 16), + _RemoteButton( + icon: Icons.skip_next, + label: 'Next', + onPressed: () => _sendCommand(RemoteCommandType.nextTrack), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _RemoteButton( + icon: Icons.replay_10, + label: 'Seek Back', + onPressed: () => _sendCommand(RemoteCommandType.seekBackward), + ), + const SizedBox(width: 16), + _RemoteButton(icon: Icons.stop, label: 'Stop', onPressed: () => _sendCommand(RemoteCommandType.stop)), + const SizedBox(width: 16), + _RemoteButton( + icon: Icons.forward_10, + label: 'Seek Fwd', + onPressed: () => _sendCommand(RemoteCommandType.seekForward), + ), + ], + ), + const SizedBox(height: 32), + Text('Volume', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _RemoteButton( + icon: Icons.volume_off, + label: 'Mute', + onPressed: () => _sendCommand(RemoteCommandType.volumeMute), + ), + const SizedBox(width: 16), + _RemoteButton( + icon: Icons.volume_down, + label: 'Down', + onPressed: () => _sendCommand(RemoteCommandType.volumeDown), + ), + const SizedBox(width: 16), + _RemoteButton( + icon: Icons.volume_up, + label: 'Up', + onPressed: () => _sendCommand(RemoteCommandType.volumeUp), + ), + ], + ), + ], + ); + } + + Widget _buildQuickActionsTab() { + final isPlayerActive = context.watch().isPlayerActive; + + return Column( + children: [ + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + if (!isPlayerActive) + _RemoteCard(icon: Icons.search, label: 'Search', onPressed: _showSearchSheet), + if (isPlayerActive) ...[ + _RemoteCard( + icon: Icons.fullscreen, + label: 'Fullscreen', + onPressed: () => _sendCommand(RemoteCommandType.fullscreen), + ), + _RemoteCard( + icon: Icons.subtitles, + label: 'Subtitles', + onPressed: () => _sendCommand(RemoteCommandType.subtitles), + ), + _RemoteCard( + icon: Icons.audiotrack, + label: 'Audio', + onPressed: () => _sendCommand(RemoteCommandType.audioTracks), + ), + ], + ], + ), + ], + ); + } +} + +class _DPad extends StatelessWidget { + final Function(RemoteCommandType) onCommand; + + const _DPad({required this.onCommand}); + + @override + Widget build(BuildContext context) { + const size = 72.0; + const gap = 8.0; + const total = size * 3 + gap * 2; + + return SizedBox( + width: total, + height: total, + child: Stack( + children: [ + Positioned( + left: size + gap, + top: 0, + child: _DPadButton( + icon: Icons.arrow_drop_up, + onPressed: () => onCommand(RemoteCommandType.dpadUp), + size: size, + ), + ), + Positioned( + left: size + gap, + bottom: 0, + child: _DPadButton( + icon: Icons.arrow_drop_down, + onPressed: () => onCommand(RemoteCommandType.dpadDown), + size: size, + ), + ), + Positioned( + left: 0, + top: size + gap, + child: _DPadButton( + icon: Icons.arrow_left, + onPressed: () => onCommand(RemoteCommandType.dpadLeft), + size: size, + ), + ), + Positioned( + right: 0, + top: size + gap, + child: _DPadButton( + icon: Icons.arrow_right, + onPressed: () => onCommand(RemoteCommandType.dpadRight), + size: size, + ), + ), + Positioned( + left: size + gap, + top: size + gap, + child: _DPadButton( + icon: Icons.check, + label: 'OK', + onPressed: () => onCommand(RemoteCommandType.select), + size: size, + isPrimary: true, + ), + ), + ], + ), + ); + } +} + +class _DPadButton extends StatelessWidget { + final IconData icon; + final String? label; + final VoidCallback onPressed; + final double size; + final bool isPrimary; + + const _DPadButton({ + required this.icon, + this.label, + required this.onPressed, + required this.size, + this.isPrimary = false, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: ElevatedButton( + onPressed: () { + HapticFeedback.lightImpact(); + onPressed(); + }, + style: ElevatedButton.styleFrom( + padding: EdgeInsets.zero, + shape: const CircleBorder(), + backgroundColor: isPrimary ? Theme.of(context).colorScheme.primary : null, + foregroundColor: isPrimary ? Theme.of(context).colorScheme.onPrimary : null, + ), + child: label != null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 24), + const SizedBox(height: 2), + Text(label!, style: const TextStyle(fontSize: 10)), + ], + ) + : Icon(icon, size: 36), + ), + ); + } +} + +class _RemoteButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + final double size; + final double iconSize; + + const _RemoteButton({ + required this.icon, + required this.label, + required this.onPressed, + this.size = 56, + this.iconSize = 24, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: FilledButton( + onPressed: () { + HapticFeedback.lightImpact(); + onPressed(); + }, + style: FilledButton.styleFrom(padding: EdgeInsets.zero, shape: const CircleBorder()), + child: Icon(icon, size: iconSize), + ), + ), + const SizedBox(height: 4), + Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center), + ], + ); + } +} + +class _RemoteChip extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + + const _RemoteChip({required this.icon, required this.label, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: Icon(icon, size: 18), + label: Text(label), + onPressed: () { + HapticFeedback.lightImpact(); + onPressed(); + }, + ); + } +} + +class _SearchBottomSheet extends StatefulWidget { + final CompanionRemoteProvider provider; + + const _SearchBottomSheet({required this.provider}); + + @override + State<_SearchBottomSheet> createState() => _SearchBottomSheetState(); +} + +class _SearchBottomSheetState extends State<_SearchBottomSheet> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit(String text) { + final trimmed = text.trim(); + if (trimmed.isNotEmpty) { + widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed}); + } + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search on desktop...', + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(100)), + ), + onSubmitted: _submit, + ), + const SizedBox(height: 16), + ], + ), + ); + } +} + +class _RemoteCard extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + + const _RemoteCard({required this.icon, required this.label, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 100, + height: 100, + child: Card( + child: InkWell( + onTap: () { + HapticFeedback.lightImpact(); + onPressed(); + }, + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 32), + const SizedBox(height: 8), + Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/companion_remote/pairing_screen.dart b/lib/screens/companion_remote/pairing_screen.dart new file mode 100644 index 00000000..d06956ff --- /dev/null +++ b/lib/screens/companion_remote/pairing_screen.dart @@ -0,0 +1,622 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/companion_remote_provider.dart'; +import '../../models/companion_remote/recent_remote_session.dart'; +import '../../utils/app_logger.dart'; + +class PairingScreen extends StatefulWidget { + const PairingScreen({super.key}); + + @override + State createState() => _PairingScreenState(); +} + +class _PairingScreenState extends State { + final _hostAddressController = TextEditingController(); + final _sessionIdController = TextEditingController(); + final _pinController = TextEditingController(); + final _formKey = GlobalKey(); + bool _isConnecting = false; + String? _connectingSessionId; + bool _isDiscovering = false; + String? _errorMessage; + int _selectedTab = 0; + + // QR scanner state + MobileScannerController? _scannerController; + String? _lastScannedCode; + + bool get _isMobile => Platform.isAndroid || Platform.isIOS; + + // Tab indices shift when scan tab is present + int get _scanTabIndex => _isMobile ? 1 : -1; + int get _manualTabIndex => _isMobile ? 2 : 1; + + @override + void initState() { + super.initState(); + _loadRecentSessions(); + } + + @override + void dispose() { + _hostAddressController.dispose(); + _sessionIdController.dispose(); + _pinController.dispose(); + _scannerController?.dispose(); + super.dispose(); + } + + Future _loadRecentSessions() async { + setState(() { + _isDiscovering = true; + _errorMessage = null; + }); + + try { + await context.read().loadRecentSessions(); + setState(() { + _isDiscovering = false; + }); + } catch (e) { + appLogger.e('Failed to load recent sessions', error: e); + setState(() { + _isDiscovering = false; + _errorMessage = 'Failed to load recent sessions: ${e.toString()}'; + }); + } + } + + Future _connectToRecentSession(RecentRemoteSession session) async { + setState(() { + _isConnecting = true; + _connectingSessionId = session.sessionId; + _errorMessage = null; + }); + + try { + await context.read().connectToRecentSession(session); + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + appLogger.e('Failed to connect to recent session', error: e); + setState(() { + _isConnecting = false; + _connectingSessionId = null; + _errorMessage = _parseErrorMessage(e.toString()); + }); + } + } + + Future _connect() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isConnecting = true; + _errorMessage = null; + }); + + try { + final provider = context.read(); + await provider.joinSession( + _sessionIdController.text.trim().toUpperCase(), + _pinController.text.trim(), + _hostAddressController.text.trim(), + ); + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + appLogger.e('Failed to join remote session', error: e); + setState(() { + _isConnecting = false; + _errorMessage = _parseErrorMessage(e.toString()); + }); + } + } + + String _parseErrorMessage(String error) { + if (error.contains('timeout') || error.contains('Timed out')) { + return 'Connection timed out. Please check the session ID and PIN.'; + } else if (error.contains('Failed to connect')) { + return 'Could not find the session. Please check your credentials.'; + } + return 'Failed to connect: ${error.replaceAll('Exception: ', '')}'; + } + + void _handleQrCode(String data) { + // Debounce: don't process the same code twice + if (data == _lastScannedCode) return; + _lastScannedCode = data; + + // New format: ip|port|sessionId|pin (4 parts separated by pipe) + final parts = data.split('|'); + if (parts.length == 4) { + final ip = parts[0]; + final port = parts[1]; + final sessionId = parts[2]; + final pin = parts[3]; + final hostAddress = '$ip:$port'; + + _scannerController?.stop(); + setState(() { + _errorMessage = null; + _isConnecting = true; + }); + // Connect directly instead of going through _connect() which requires Form validation + _connectWithCredentials(sessionId, pin, hostAddress); + } else { + setState(() { + _errorMessage = 'Invalid QR code format - expected 4 parts (ip|port|sessionId|pin)'; + }); + } + } + + Future _connectWithCredentials(String sessionId, String pin, String hostAddress) async { + try { + final provider = context.read(); + await provider.joinSession(sessionId.trim().toUpperCase(), pin.trim(), hostAddress.trim()); + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + appLogger.e('Failed to join remote session', error: e); + _lastScannedCode = null; // Allow re-scanning + setState(() { + _isConnecting = false; + _errorMessage = _parseErrorMessage(e.toString()); + }); + _scannerController?.start(); + } + } + + Future _pasteFromClipboard(TextEditingController controller) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + setState(() { + controller.text = data!.text!; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Connect to Device'), + actions: [ + if (_selectedTab == 0) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _isDiscovering ? null : _loadRecentSessions, + tooltip: 'Refresh', + ), + ], + ), + body: Column( + children: [ + SegmentedButton( + segments: [ + const ButtonSegment(value: 0, label: Text('Recent'), icon: Icon(Icons.history)), + if (_isMobile) + ButtonSegment(value: _scanTabIndex, label: const Text('Scan'), icon: const Icon(Icons.qr_code_scanner)), + ButtonSegment(value: _manualTabIndex, label: const Text('Manual'), icon: const Icon(Icons.keyboard)), + ], + selected: {_selectedTab}, + onSelectionChanged: (Set selection) { + setState(() { + _selectedTab = selection.first; + }); + }, + ), + Expanded(child: _buildTabContent()), + ], + ), + ); + } + + Widget _buildTabContent() { + if (_selectedTab == 0) return _buildDiscoveryTab(); + if (_selectedTab == _scanTabIndex) return _buildScanTab(); + return _buildManualEntryTab(); + } + + Widget _buildScanTab() { + return Column( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: MobileScanner( + controller: _scannerController ??= MobileScannerController(), + onDetect: (capture) { + final barcode = capture.barcodes.firstOrNull; + if (barcode?.rawValue != null) { + _handleQrCode(barcode!.rawValue!); + } + }, + errorBuilder: (context, error, child) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.no_photography, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + error.errorCode == MobileScannerErrorCode.permissionDenied + ? 'Camera permission is required to scan QR codes.\nPlease grant camera access in your device settings.' + : 'Could not start camera: ${error.errorDetails?.message ?? error.errorCode.name}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + children: [ + Text( + 'Point your camera at the QR code shown on your desktop', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ], + ); + } + + Widget _buildDiscoveryTab() { + return Consumer( + builder: (context, provider, child) { + final sessions = provider.recentSessions; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.history, size: 64, color: Colors.blue), + const SizedBox(height: 24), + Text( + 'Recent Connections', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Quickly reconnect to previously paired devices', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (_isDiscovering) ...[ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + Text('Loading...', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center), + ] else if (sessions.isEmpty) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Icon(Icons.devices_other, size: 48, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + Text('No recent connections', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Connect to a device using Manual entry to get started', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ] else ...[ + ...sessions.map((session) { + final isThisConnecting = _isConnecting && _connectingSessionId == session.sessionId; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const Icon(Icons.computer, size: 40), + title: Text(session.deviceName), + subtitle: Text( + '${session.platform}\n' + 'Session: ${session.sessionId}\n' + 'Last used: ${_formatDate(session.lastConnected)}', + ), + isThreeLine: true, + trailing: isThisConnecting + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.arrow_forward), + onTap: _isConnecting ? null : () => _connectToRecentSession(session), + onLongPress: () => _showRemoveSessionDialog(session), + ), + ); + }), + ], + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + }, + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } else if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${date.month}/${date.day}/${date.year}'; + } + } + + Future _showRemoveSessionDialog(RecentRemoteSession session) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Recent Connection'), + content: Text('Remove "${session.deviceName}" from recent connections?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Remove')), + ], + ), + ); + + if (confirmed == true && mounted) { + await context.read().removeRecentSession(session.sessionId); + } + } + + Widget _buildManualEntryTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.keyboard, size: 64, color: Colors.blue), + const SizedBox(height: 24), + Text('Pair with Desktop', style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center), + const SizedBox(height: 8), + Text( + 'Enter the session details shown on your desktop device', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (_errorMessage != null) ...[ + Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + TextFormField( + controller: _hostAddressController, + decoration: InputDecoration( + labelText: 'Host Address', + hintText: '192.168.1.100:48632', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.computer), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () => _pasteFromClipboard(_hostAddressController), + tooltip: 'Paste', + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter host address'; + } + // Validate IP:port format + final parts = value.split(':'); + if (parts.length != 2) { + return 'Format must be IP:port (e.g., 192.168.1.100:48632)'; + } + return null; + }, + enabled: !_isConnecting, + ), + const SizedBox(height: 16), + TextFormField( + controller: _sessionIdController, + decoration: InputDecoration( + labelText: 'Session ID', + hintText: 'Enter 8-character session ID', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.vpn_key), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () => _pasteFromClipboard(_sessionIdController), + tooltip: 'Paste', + ), + ), + textCapitalization: TextCapitalization.characters, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')), + LengthLimitingTextInputFormatter(8), + TextInputFormatter.withFunction((oldValue, newValue) { + return newValue.copyWith(text: newValue.text.toUpperCase()); + }), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a session ID'; + } + if (value.length != 8) { + return 'Session ID must be 8 characters'; + } + return null; + }, + enabled: !_isConnecting, + ), + const SizedBox(height: 16), + TextFormField( + controller: _pinController, + decoration: InputDecoration( + labelText: 'PIN', + hintText: 'Enter 6-digit PIN', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () => _pasteFromClipboard(_pinController), + tooltip: 'Paste', + ), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a PIN'; + } + if (value.length != 6) { + return 'PIN must be 6 digits'; + } + return null; + }, + enabled: !_isConnecting, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _isConnecting ? null : _connect, + icon: _isConnecting + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.link), + label: Text(_isConnecting ? 'Connecting...' : 'Connect'), + ), + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 16), + Text('Tips', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _buildTipCard( + context, + Icons.computer, + 'Open Plezy on your desktop and enable Companion Remote from settings or menu', + ), + if (_isMobile) ...[ + const SizedBox(height: 8), + _buildTipCard( + context, + Icons.qr_code, + 'Use the Scan tab to quickly pair by scanning the QR code on your desktop', + ), + ], + const SizedBox(height: 8), + _buildTipCard(context, Icons.wifi, 'Make sure both devices are on the same WiFi network'), + ], + ), + ), + ); + } + + Widget _buildTipCard(BuildContext context, IconData icon, String text) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded(child: Text(text, style: Theme.of(context).textTheme.bodySmall)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c2bcb646..beb6874b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -39,6 +39,9 @@ import 'auth_screen.dart'; import 'libraries/state_messages.dart'; import 'main_screen.dart'; import '../watch_together/watch_together.dart'; +import '../providers/companion_remote_provider.dart'; +import '../widgets/companion_remote/remote_session_dialog.dart'; +import 'companion_remote/mobile_remote_screen.dart'; class DiscoverScreen extends StatefulWidget { final VoidCallback? onBecameVisible; @@ -118,6 +121,9 @@ class _DiscoverScreenState extends State _refreshContinueWatching(); } + // Track initial load so we can focus hero when content first appears + bool _initialLoadComplete = false; + // Hub navigation keys GlobalKey? _continueWatchingHubKey; final List> _hubKeys = []; @@ -126,9 +132,11 @@ class _DiscoverScreenState extends State late FocusNode _heroFocusNode; late FocusNode _refreshButtonFocusNode; late FocusNode _watchTogetherButtonFocusNode; + late FocusNode _companionRemoteButtonFocusNode; late FocusNode _userButtonFocusNode; bool _isRefreshFocused = false; bool _isWatchTogetherFocused = false; + bool _isCompanionRemoteFocused = false; bool _isUserFocused = false; /// Get the correct PlexClient for an item's server @@ -242,9 +250,11 @@ class _DiscoverScreenState extends State _heroFocusNode = FocusNode(debugLabel: 'hero_section'); _refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button'); _watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button'); + _companionRemoteButtonFocusNode = FocusNode(debugLabel: 'companion_remote_button'); _userButtonFocusNode = FocusNode(debugLabel: 'user_button'); _refreshButtonFocusNode.addListener(_onRefreshFocusChange); _watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange); + _companionRemoteButtonFocusNode.addListener(_onCompanionRemoteFocusChange); _userButtonFocusNode.addListener(_onUserFocusChange); _loadContent(); _startAutoScroll(); @@ -266,6 +276,14 @@ class _DiscoverScreenState extends State } } + void _onCompanionRemoteFocusChange() { + if (mounted) { + setState(() { + _isCompanionRemoteFocused = _companionRemoteButtonFocusNode.hasFocus; + }); + } + } + void _onUserFocusChange() { if (mounted) { setState(() { @@ -386,7 +404,13 @@ class _DiscoverScreenState extends State return KeyEventResult.handled; } - // RIGHT: Move to user button + // RIGHT: Move to companion remote button (desktop only) + if (key.isRightKey && PlatformDetector.isDesktop(context)) { + _companionRemoteButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // RIGHT: Move to user button (non-desktop, skip companion remote) if (key.isRightKey) { _userButtonFocusNode.requestFocus(); return KeyEventResult.handled; @@ -406,6 +430,46 @@ class _DiscoverScreenState extends State return KeyEventResult.ignored; } + /// Handle key events for the companion remote button in app bar + KeyEventResult _handleCompanionRemoteKeyEvent(FocusNode node, KeyEvent event) { + if (!event.isActionable) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey; + + // DOWN: Return to hero + if (key.isDownKey) { + _heroFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // LEFT: Move to watch together button + if (key.isLeftKey) { + _watchTogetherButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // RIGHT: Move to user button + if (key.isRightKey) { + _userButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // UP: Block at boundary + if (key.isUpKey) { + return KeyEventResult.handled; + } + + // SELECT: Show companion remote dialog + if (key.isSelectKey) { + RemoteSessionDialog.show(context); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + /// Handle key events for the user button in app bar KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) { if (!event.isActionable) { @@ -420,7 +484,13 @@ class _DiscoverScreenState extends State return KeyEventResult.handled; } - // LEFT: Move to watch together button + // LEFT: Move to companion remote button (desktop only) + if (key.isLeftKey && PlatformDetector.isDesktop(context)) { + _companionRemoteButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // LEFT: Move to watch together button (non-desktop, skip companion remote) if (key.isLeftKey) { _watchTogetherButtonFocusNode.requestFocus(); return KeyEventResult.handled; @@ -454,6 +524,8 @@ class _DiscoverScreenState extends State _refreshButtonFocusNode.dispose(); _watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange); _watchTogetherButtonFocusNode.dispose(); + _companionRemoteButtonFocusNode.removeListener(_onCompanionRemoteFocusChange); + _companionRemoteButtonFocusNode.dispose(); _userButtonFocusNode.removeListener(_onUserFocusChange); _userButtonFocusNode.dispose(); super.dispose(); @@ -645,6 +717,16 @@ class _DiscoverScreenState extends State _heroController.jumpToPage(0); } + // On initial load, focus the hero so the user starts on content (not the toolbar) + if (!_initialLoadComplete && onDeck.isNotEmpty) { + _initialLoadComplete = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _heroFocusNode.canRequestFocus) { + _heroFocusNode.requestFocus(); + } + }); + } + // Wait for global hubs final allHubs = await hubsFuture; @@ -1037,6 +1119,59 @@ class _DiscoverScreenState extends State ); }, ), + // Companion Remote button + Consumer( + builder: (context, companionRemote, child) { + final isDesktop = PlatformDetector.isDesktop(context); + + return Focus( + focusNode: isDesktop ? _companionRemoteButtonFocusNode : null, + onKeyEvent: isDesktop ? _handleCompanionRemoteKeyEvent : null, + child: Container( + decoration: BoxDecoration( + color: isDesktop && _isCompanionRemoteFocused + ? Colors.white.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.phone_android_rounded, + fill: companionRemote.isConnected ? 1 : 0, + color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white, + ), + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); + } + }, + tooltip: 'Companion Remote', + ), + // Badge showing connection status + if (companionRemote.isConnected) + Positioned( + top: 6, + right: 6, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1), + ), + ), + ), + ], + ), + ), + ); + }, + ), Consumer( builder: (context, userProvider, child) { return Focus( @@ -1233,8 +1368,10 @@ class _DiscoverScreenState extends State ], ), ), - // Overlaid app bar - Positioned(top: 0, left: 0, right: 0, child: _buildOverlaidAppBar()), + // Overlaid app bar — excluded from default focus traversal so that + // initial/tab-switch focus lands on content (hero/hubs), not the toolbar. + // Toolbar buttons are still reachable via explicit UP from hero section. + Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())), ], ), ); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index c64e6dd3..dc4d0103 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -28,6 +28,8 @@ import '../services/settings_service.dart'; import '../providers/offline_mode_provider.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; +import '../services/companion_remote/companion_remote_receiver.dart'; +import '../providers/companion_remote_provider.dart'; import '../utils/desktop_window_padding.dart'; import '../widgets/side_navigation_rail.dart'; import '../focus/key_event_utils.dart'; @@ -362,6 +364,8 @@ class _MainScreenState extends State with RouteAware, WindowListener } } + bool _companionRemoteSetup = false; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -378,9 +382,71 @@ class _MainScreenState extends State with RouteAware, WindowListener _offlineModeProvider!.addListener(_handleOfflineStatusChanged); } + // Wire up Companion Remote command routing (desktop only, once) + if (!_companionRemoteSetup && PlatformDetector.isDesktop(context)) { + _companionRemoteSetup = true; + _setupCompanionRemote(); + } + routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); } + void _setupCompanionRemote() { + final companionRemote = context.read(); + companionRemote.onCommandReceived = (command) { + if (mounted) { + CompanionRemoteReceiver.instance.handleCommand(command, context); + } + }; + + final receiver = CompanionRemoteReceiver.instance; + final tabCount = _getVisibleTabs(_isOffline).length; + + receiver.onTabNext = () { + _selectTab((_currentIndex + 1) % tabCount); + }; + receiver.onTabPrevious = () { + _selectTab((_currentIndex - 1 + tabCount) % tabCount); + }; + receiver.onTabDiscover = () { + final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline); + if (idx >= 0) _selectTab(idx); + }; + receiver.onTabLibraries = () { + final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline); + if (idx >= 0) _selectTab(idx); + }; + receiver.onTabSearch = () { + final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline); + if (idx >= 0) _selectTab(idx); + }; + receiver.onTabDownloads = () { + final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline); + if (idx >= 0) _selectTab(idx); + }; + receiver.onTabSettings = () { + final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline); + if (idx >= 0) _selectTab(idx); + }; + receiver.onHome = () { + final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline); + if (idx >= 0) _selectTab(idx); + }; + receiver.onSearchAction = (query) { + final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline); + if (idx >= 0) { + _selectTab(idx); + if (query != null && query.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchKey.currentState case final SearchInputFocusable searchable) { + searchable.setSearchQuery(query); + } + }); + } + } + }; + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -392,6 +458,21 @@ class _MainScreenState extends State with RouteAware, WindowListener _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); _sidebarFocusScope.dispose(); _contentFocusScope.dispose(); + + // Clean up companion remote callbacks + if (_companionRemoteSetup) { + final receiver = CompanionRemoteReceiver.instance; + receiver.onTabNext = null; + receiver.onTabPrevious = null; + receiver.onTabDiscover = null; + receiver.onTabLibraries = null; + receiver.onTabSearch = null; + receiver.onTabDownloads = null; + receiver.onTabSettings = null; + receiver.onHome = null; + receiver.onSearchAction = null; + } + super.dispose(); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 86e0eff1..b404ab00 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -133,6 +133,12 @@ class _SearchScreenState extends State with Refreshable, FullRefre FocusUtils.requestFocusAfterBuild(this, _searchFocusNode); } + /// Set the search query externally (e.g. from companion remote) + @override + void setSearchQuery(String query) { + _searchController.text = query; + } + // Public method to fully reload all content (for profile switches) @override void fullRefresh() { diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index a7ae977f..cee45320 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -27,6 +27,9 @@ import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/tv_number_spinner.dart'; import 'hotkey_recorder_widget.dart'; +import '../../providers/companion_remote_provider.dart'; +import '../../screens/companion_remote/mobile_remote_screen.dart'; +import '../../widgets/companion_remote/remote_session_dialog.dart'; import 'about_screen.dart'; import 'logs_screen.dart'; import 'mpv_config_screen.dart'; @@ -206,6 +209,8 @@ class _SettingsScreenState extends State with FocusableTab { _buildDownloadsSection(), const SizedBox(height: 24), if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)], + _buildCompanionRemoteSection(), + const SizedBox(height: 24), _buildAdvancedSection(), const SizedBox(height: 24), if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)], @@ -776,6 +781,51 @@ class _SettingsScreenState extends State with FocusableTab { ); } + Widget _buildCompanionRemoteSection() { + return Consumer( + builder: (context, companionRemote, child) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Companion Remote', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (PlatformDetector.isDesktop(context)) + ListTile( + leading: const AppIcon(Symbols.phone_android_rounded, fill: 1), + title: const Text('Host Remote Session'), + subtitle: companionRemote.isConnected + ? Text('Connected to ${companionRemote.connectedDevice?.name}') + : const Text('Control this device with your phone'), + trailing: companionRemote.isConnected + ? const AppIcon(Symbols.check_circle_rounded, fill: 1, color: Colors.green) + : const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () => RemoteSessionDialog.show(context), + ) + else + ListTile( + leading: const AppIcon(Symbols.phone_android_rounded, fill: 1), + title: const Text('Remote Control'), + subtitle: companionRemote.isConnected + ? Text('Connected to ${companionRemote.connectedDevice?.name}') + : const Text('Control a desktop device'), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen())); + }, + ), + ], + ), + ); + }, + ); + } + Widget _buildAdvancedSection() { return Card( child: Column( diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 365c2c5b..f5e9f936 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -22,6 +22,10 @@ import '../models/plex_media_info.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../models/companion_remote/remote_command_type.dart'; +import '../providers/companion_remote_provider.dart'; +import '../services/companion_remote/companion_remote_receiver.dart'; +import '../services/macos_window_service.dart'; import '../services/discord_rpc_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; @@ -38,11 +42,13 @@ import '../providers/shader_provider.dart'; import '../providers/user_profile_provider.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/player_utils.dart'; import '../utils/orientation_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/language_codes.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/track_label_builder.dart' as tlb; import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/video_controls/video_controls.dart'; @@ -132,6 +138,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Watch Together provider reference (stored early to use in dispose) WatchTogetherProvider? _watchTogetherProvider; + // Companion remote state (stored early for use in dispose) + CompanionRemoteProvider? _companionRemoteProvider; + VoidCallback? _savedOnHome; + /// Get the correct PlexClient for this metadata's server PlexClient _getClientForMetadata(BuildContext context) { return context.getClientForServer(widget.metadata.serverId!); @@ -205,6 +215,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Register app lifecycle observer WidgetsBinding.instance.addObserver(this); + // Wire companion remote playback callbacks + _setupCompanionRemoteCallbacks(); + // Initialize player asynchronously with buffer size from settings _initializePlayer(); } @@ -1127,6 +1140,160 @@ class VideoPlayerScreenState extends State with WidgetsBindin navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true); } + void _setupCompanionRemoteCallbacks() { + final receiver = CompanionRemoteReceiver.instance; + receiver.onStop = () { + if (mounted) _handleBackButton(); + }; + receiver.onNextTrack = () { + if (mounted && _nextEpisode != null) _playNext(); + }; + receiver.onPreviousTrack = () { + if (mounted && _previousEpisode != null) _playPrevious(); + }; + receiver.onSeekForward = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + seekWithClamping(player!, Duration(seconds: settings.getSeekTimeSmall())); + }; + receiver.onSeekBackward = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + seekWithClamping(player!, Duration(seconds: -settings.getSeekTimeSmall())); + }; + receiver.onVolumeUp = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final maxVol = settings.getMaxVolume().toDouble(); + final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol); + player!.setVolume(newVolume); + settings.setVolume(newVolume); + }; + receiver.onVolumeDown = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final maxVol = settings.getMaxVolume().toDouble(); + final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol); + player!.setVolume(newVolume); + settings.setVolume(newVolume); + }; + receiver.onVolumeMute = () async { + if (player == null) return; + final settings = await SettingsService.getInstance(); + final newVolume = player!.state.volume > 0 ? 0.0 : 100.0; + player!.setVolume(newVolume); + settings.setVolume(newVolume); + }; + receiver.onSubtitles = _cycleSubtitleTrack; + receiver.onAudioTracks = _cycleAudioTrack; + receiver.onFullscreen = _toggleFullscreen; + + // Override home to exit the player first (main screen handler runs after pop) + _savedOnHome = receiver.onHome; + receiver.onHome = () { + if (mounted) _handleBackButton(); + }; + + // Store provider reference for use in dispose and notify remote + try { + _companionRemoteProvider = context.read(); + _companionRemoteProvider!.sendCommand( + RemoteCommandType.syncState, + data: {'playerActive': true}, + ); + } catch (_) {} + } + + void _cleanupCompanionRemoteCallbacks() { + final receiver = CompanionRemoteReceiver.instance; + receiver.onStop = null; + receiver.onNextTrack = null; + receiver.onPreviousTrack = null; + receiver.onSeekForward = null; + receiver.onSeekBackward = null; + receiver.onVolumeUp = null; + receiver.onVolumeDown = null; + receiver.onVolumeMute = null; + receiver.onSubtitles = null; + receiver.onAudioTracks = null; + receiver.onFullscreen = null; + receiver.onHome = _savedOnHome; + _savedOnHome = null; + + // Notify remote that player is no longer active + _companionRemoteProvider?.sendCommand( + RemoteCommandType.syncState, + data: {'playerActive': false}, + ); + _companionRemoteProvider = null; + } + + void _cycleSubtitleTrack() { + if (player == null) return; + final tracks = player!.state.tracks.subtitle.where((t) => t.id != 'auto').toList(); + if (tracks.isEmpty) return; + + final current = player!.state.track.subtitle; + // tracks includes 'no' (off). Find current index and advance. + final currentIndex = tracks.indexWhere((t) => t.id == current?.id); + final nextIndex = (currentIndex + 1) % tracks.length; + final next = tracks[nextIndex]; + player!.selectSubtitleTrack(next); + _onSubtitleTrackChanged(next); + + if (mounted) { + final label = next.id == 'no' + ? 'Subtitles: Off' + : 'Subtitles: ${tlb.TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}'; + showAppSnackBar(context, label, duration: const Duration(seconds: 1)); + } + } + + void _cycleAudioTrack() { + if (player == null) return; + final tracks = player!.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); + if (tracks.length <= 1) return; + + final current = player!.state.track.audio; + final currentIndex = tracks.indexWhere((t) => t.id == current?.id); + final nextIndex = (currentIndex + 1) % tracks.length; + final next = tracks[nextIndex]; + player!.selectAudioTrack(next); + _onAudioTrackChanged(next); + + if (mounted) { + final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + showAppSnackBar(context, label, duration: const Duration(seconds: 1)); + } + } + + Future _toggleFullscreen() async { + if (PlatformDetector.isMobile(context)) return; + final isCurrentlyFullscreen = await windowManager.isFullScreen(); + if (Platform.isMacOS) { + if (isCurrentlyFullscreen) { + await MacOSWindowService.exitFullscreen(); + } else { + await MacOSWindowService.enterFullscreen(); + } + } else { + await windowManager.setFullScreen(!isCurrentlyFullscreen); + } + } + + /// Exit fullscreen before leaving the player (Windows/Linux only). + /// macOS is excluded because we can't distinguish native fullscreen + /// from maximized state, so we leave the window state unchanged. + Future _exitFullscreenIfNeeded() async { + if (Platform.isWindows || Platform.isLinux) { + final isFullscreen = await windowManager.isFullScreen(); + if (isFullscreen) { + await windowManager.setFullScreen(false); + await Future.delayed(const Duration(milliseconds: 100)); + } + } + } + /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton() async { @@ -1146,16 +1313,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (confirmed && mounted) { await _watchTogetherProvider!.leaveSession(); if (mounted) { - // Exit fullscreen before leaving player (Windows/Linux only) - if (Platform.isWindows || Platform.isLinux) { - final isFullscreen = await windowManager.isFullScreen(); - if (isFullscreen) { - await windowManager.setFullScreen(false); - // Wait for a frame to allow window manager to process the fullscreen exit - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - } - } + await _exitFullscreenIfNeeded(); if (!mounted) return; _isExiting.value = true; Navigator.of(context).pop(true); @@ -1164,16 +1322,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } - // Exit fullscreen before leaving player (Windows/Linux only) - if (Platform.isWindows || Platform.isLinux) { - final isFullscreen = await windowManager.isFullScreen(); - if (isFullscreen) { - await windowManager.setFullScreen(false); - // Wait for a frame to allow window manager to process the fullscreen exit - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - } - } + await _exitFullscreenIfNeeded(); // Default behavior for hosts or non-session users if (!mounted) return; @@ -1189,6 +1338,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Unregister app lifecycle observer WidgetsBinding.instance.removeObserver(this); + // Clean up companion remote playback callbacks + _cleanupCompanionRemoteCallbacks(); + // Notify Watch Together guests that host is exiting the player // Use stored reference since context.read() may fail in dispose // Skip if replacing with another video (episode navigation) diff --git a/lib/services/companion_remote/companion_remote_discovery_service.dart b/lib/services/companion_remote/companion_remote_discovery_service.dart new file mode 100644 index 00000000..f5136df9 --- /dev/null +++ b/lib/services/companion_remote/companion_remote_discovery_service.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../models/companion_remote/recent_remote_session.dart'; +import '../../services/storage_service.dart'; +import '../../utils/app_logger.dart'; + +/// Service for managing recent Companion Remote sessions +class CompanionRemoteDiscoveryService { + static const String _storageKey = 'companion_remote_recent_sessions'; + static const int _maxRecentSessions = 5; + + final _recentSessions = []; + final _recentSessionsController = StreamController>.broadcast(); + + /// Stream of recent sessions + Stream> get recentSessions => _recentSessionsController.stream; + + /// Get current list of recent sessions + List get currentSessions => List.unmodifiable(_recentSessions); + + CompanionRemoteDiscoveryService() { + _loadRecentSessions(); + } + + /// Load recent sessions from storage + Future _loadRecentSessions() async { + try { + final storage = await StorageService.getInstance(); + final json = storage.prefs.getString(_storageKey); + + if (json != null) { + final List list = jsonDecode(json); + _recentSessions.clear(); + _recentSessions.addAll(list.map((e) => RecentRemoteSession.fromJson(e as Map))); + + // Sort by last connected (most recent first) + _recentSessions.sort((a, b) => b.lastConnected.compareTo(a.lastConnected)); + + _recentSessionsController.add(currentSessions); + appLogger.d('Loaded ${_recentSessions.length} recent remote sessions'); + } + } catch (e) { + appLogger.e('Failed to load recent sessions', error: e); + } + } + + /// Save recent sessions to storage + Future _saveRecentSessions() async { + try { + final storage = await StorageService.getInstance(); + final json = jsonEncode(_recentSessions.map((e) => e.toJson()).toList()); + await storage.prefs.setString(_storageKey, json); + appLogger.d('Saved ${_recentSessions.length} recent remote sessions'); + } catch (e) { + appLogger.e('Failed to save recent sessions', error: e); + } + } + + /// Add a session to recent list + Future addRecentSession(RecentRemoteSession session) async { + // Remove existing entry for this session ID + _recentSessions.removeWhere((s) => s.sessionId == session.sessionId); + + // Add new entry at the beginning + _recentSessions.insert(0, session); + + // Limit to max sessions + if (_recentSessions.length > _maxRecentSessions) { + _recentSessions.removeRange(_maxRecentSessions, _recentSessions.length); + } + + await _saveRecentSessions(); + _recentSessionsController.add(currentSessions); + } + + /// Remove a session from recent list + Future removeRecentSession(String sessionId) async { + _recentSessions.removeWhere((s) => s.sessionId == sessionId); + await _saveRecentSessions(); + _recentSessionsController.add(currentSessions); + } + + /// Clear all recent sessions + Future clearRecentSessions() async { + _recentSessions.clear(); + await _saveRecentSessions(); + _recentSessionsController.add(currentSessions); + } + + /// Dispose resources + Future dispose() async { + await _recentSessionsController.close(); + } +} diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart new file mode 100644 index 00000000..40dc6817 --- /dev/null +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -0,0 +1,540 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:web_socket_channel/io.dart'; + +import '../../models/companion_remote/remote_command.dart'; +import '../../models/companion_remote/remote_command_type.dart'; +import '../../models/companion_remote/remote_session.dart'; +import '../../utils/app_logger.dart'; + +enum RemotePeerErrorType { + connectionFailed, + peerDisconnected, + dataChannelError, + serverError, + timeout, + invalidSession, + authFailed, + networkError, + unknown, +} + +class RemotePeerError { + final RemotePeerErrorType type; + final String message; + final dynamic originalError; + + const RemotePeerError({required this.type, required this.message, this.originalError}); + + @override + String toString() => 'RemotePeerError($type): $message'; +} + +class CompanionRemotePeerService { + // Server-side (host) fields + HttpServer? _server; + WebSocket? _clientSocket; + + // Client-side (remote) fields + IOWebSocketChannel? _channel; + + String? _sessionId; + String? _pin; + String? _myPeerId; + String? _hostAddress; // Format: "ip:port" + RemoteSessionRole? _role; + + final _commandReceivedController = StreamController.broadcast(); + final _deviceConnectedController = StreamController.broadcast(); + final _deviceDisconnectedController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _connectionStateController = StreamController.broadcast(); + + Timer? _pingTimer; + + // Auth rate limiting + int _failedAuthAttempts = 0; + DateTime? _authLockoutUntil; + static const int _maxFailedAuthAttempts = 5; + static const Duration _authLockoutDuration = Duration(seconds: 30); + + Stream get onCommandReceived => _commandReceivedController.stream; + Stream get onDeviceConnected => _deviceConnectedController.stream; + Stream get onDeviceDisconnected => _deviceDisconnectedController.stream; + Stream get onError => _errorController.stream; + Stream get onConnectionStateChanged => _connectionStateController.stream; + + String? get sessionId => _sessionId; + String? get pin => _pin; + String? get myPeerId => _myPeerId; + String? get hostAddress => _hostAddress; + RemoteSessionRole? get role => _role; + bool get isHost => _role == RemoteSessionRole.host; + bool get isConnected => _clientSocket != null || (_channel != null && _channel?.closeCode == null); + + String _generateSessionId() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final random = Random.secure(); + return List.generate(8, (index) => chars[random.nextInt(chars.length)]).join(); + } + + String _generatePin() { + final random = Random.secure(); + return List.generate(6, (index) => random.nextInt(10).toString()).join(); + } + + Future _getLocalIpAddress() async { + try { + final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4); + + // Prefer WiFi interface, then any non-loopback + for (final interface in interfaces) { + // Skip loopback + if (interface.name.toLowerCase().contains('lo')) continue; + + for (final addr in interface.addresses) { + if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) { + // Prefer names that suggest WiFi/Ethernet + if (interface.name.toLowerCase().contains('en') || + interface.name.toLowerCase().contains('wl') || + interface.name.toLowerCase().contains('eth')) { + return addr.address; + } + } + } + } + + // Fallback: return any non-loopback IPv4 + for (final interface in interfaces) { + for (final addr in interface.addresses) { + if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) { + return addr.address; + } + } + } + + throw const RemotePeerError(type: RemotePeerErrorType.networkError, message: 'No network interface found'); + } catch (e) { + appLogger.e('CompanionRemote: Failed to get local IP', error: e); + rethrow; + } + } + + Future<({String sessionId, String pin, String address})> createSession(String deviceName, String platform) async { + if (_server != null) { + await disconnect(); + } + + _role = RemoteSessionRole.host; + _sessionId = _generateSessionId(); + _pin = _generatePin(); + _myPeerId = 'host-$_sessionId'; + + try { + // Try preferred port first, fallback to OS-assigned port + const int preferredPort = 48632; + + try { + _server = await HttpServer.bind(InternetAddress.anyIPv4, preferredPort); + appLogger.d('CompanionRemote: Server bound to port $preferredPort'); + } catch (e) { + appLogger.w('CompanionRemote: Port $preferredPort occupied, using random port'); + _server = await HttpServer.bind(InternetAddress.anyIPv4, 0); + } + + final localIp = await _getLocalIpAddress(); + final port = _server!.port; + _hostAddress = '$localIp:$port'; + + appLogger.d('CompanionRemote: Host server started at $_hostAddress'); + + // Listen for WebSocket connections + _server!.listen((HttpRequest request) async { + if (request.uri.path == '/ws') { + try { + final socket = await WebSocketTransformer.upgrade(request); + _handleNewWebSocketConnection(socket, deviceName, platform); + } catch (e) { + appLogger.e('CompanionRemote: Failed to upgrade WebSocket', error: e); + } + } else { + request.response.statusCode = HttpStatus.notFound; + request.response.close(); + } + }); + + _connectionStateController.add(RemoteSessionStatus.connected); + + return (sessionId: _sessionId!, pin: _pin!, address: _hostAddress!); + } catch (e) { + appLogger.e('CompanionRemote: Failed to create server', error: e); + _errorController.add( + RemotePeerError( + type: RemotePeerErrorType.serverError, + message: 'Failed to create server: $e', + originalError: e, + ), + ); + rethrow; + } + } + + void _handleNewWebSocketConnection(WebSocket socket, String hostDeviceName, String hostPlatform) { + appLogger.d('CompanionRemote: New WebSocket connection'); + + bool isAuthenticated = false; + Timer? authTimeout; + + // Authentication timeout + authTimeout = Timer(const Duration(seconds: 10), () { + if (!isAuthenticated) { + appLogger.w('CompanionRemote: Authentication timeout'); + socket.close(4001, 'Authentication timeout'); + } + }); + + socket.listen( + (data) { + try { + final json = jsonDecode(data as String) as Map; + + if (!isAuthenticated) { + // First message must be authentication + if (json['type'] == 'auth') { + // Rate limiting: reject if locked out + if (_authLockoutUntil != null && DateTime.now().isBefore(_authLockoutUntil!)) { + appLogger.w('CompanionRemote: Auth attempt rejected (rate limited)'); + socket.add(jsonEncode({'type': 'authFailed', 'message': 'Too many attempts. Try again later.'})); + socket.close(4005, 'Rate limited'); + return; + } + + final sessionId = json['sessionId'] as String?; + final pin = json['pin'] as String?; + final deviceName = json['deviceName'] as String?; + final platform = json['platform'] as String?; + + if (sessionId == _sessionId && pin == _pin) { + _failedAuthAttempts = 0; + isAuthenticated = true; + authTimeout?.cancel(); + + // Close existing client if present + if (_clientSocket != null) { + appLogger.d('CompanionRemote: Replacing existing client connection'); + _clientSocket!.close(4004, 'Replaced by new connection'); + } + + _clientSocket = socket; + + appLogger.d('CompanionRemote: Client authenticated: $deviceName ($platform)'); + + // Send auth success + socket.add(jsonEncode({'type': 'authSuccess'})); + + // Notify connection + final device = RemoteDevice( + id: 'remote-client', + name: deviceName ?? 'Unknown Device', + platform: platform ?? 'unknown', + ); + _deviceConnectedController.add(device); + _connectionStateController.add(RemoteSessionStatus.connected); + + // Send device info + sendDeviceInfo(hostDeviceName, hostPlatform); + + // Note: Client sends keepalive pings, host only responds with pongs + } else { + _failedAuthAttempts++; + if (_failedAuthAttempts >= _maxFailedAuthAttempts) { + _authLockoutUntil = DateTime.now().add(_authLockoutDuration); + appLogger.w('CompanionRemote: Too many failed auth attempts, locked out for ${_authLockoutDuration.inSeconds}s'); + } + appLogger.w('CompanionRemote: Invalid credentials (attempt $_failedAuthAttempts/$_maxFailedAuthAttempts)'); + socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'})); + socket.close(4003, 'Invalid credentials'); + } + } else { + appLogger.w('CompanionRemote: Expected auth, got ${json['type']}'); + socket.close(4002, 'Authentication required'); + } + } else { + // Handle regular commands + final command = RemoteCommand.fromJson(json); + appLogger.d('CompanionRemote: Received command: ${command.type}'); + + if (_shouldSendAck(command)) { + _sendAck(command); + } + + _commandReceivedController.add(command); + + if (command.type == RemoteCommandType.ping) { + _sendPong(); + } + } + } catch (e) { + appLogger.e('CompanionRemote: Failed to process message', error: e); + } + }, + onDone: () { + authTimeout?.cancel(); + appLogger.d('CompanionRemote: WebSocket connection closed'); + if (isAuthenticated) { + _clientSocket = null; + _deviceDisconnectedController.add(null); + _connectionStateController.add(RemoteSessionStatus.disconnected); + _stopPingTimer(); + } + }, + onError: (error) { + authTimeout?.cancel(); + appLogger.e('CompanionRemote: WebSocket error', error: error); + _errorController.add( + RemotePeerError( + type: RemotePeerErrorType.dataChannelError, + message: 'WebSocket error: $error', + originalError: error, + ), + ); + }, + ); + } + + Future joinSession(String sessionId, String pin, String deviceName, String platform, String hostAddress) async { + if (_channel != null) { + await disconnect(); + } + + _role = RemoteSessionRole.remote; + _sessionId = sessionId.toUpperCase(); + _pin = pin; + _hostAddress = hostAddress; + _myPeerId = 'remote-${Random.secure().nextInt(99999)}'; + + final completer = Completer(); + + try { + final url = 'ws://$hostAddress/ws'; + appLogger.d('CompanionRemote: Connecting to $url'); + + _connectionStateController.add(RemoteSessionStatus.connecting); + + _channel = IOWebSocketChannel.connect(Uri.parse(url)); + + // Send authentication message + final authMessage = jsonEncode({ + 'type': 'auth', + 'sessionId': _sessionId, + 'pin': _pin, + 'deviceName': deviceName, + 'platform': platform, + }); + _channel!.sink.add(authMessage); + + // Listen for messages + _channel!.stream.listen( + (data) { + try { + final json = jsonDecode(data as String) as Map; + final messageType = json['type'] as String?; + + if (messageType == 'authSuccess') { + appLogger.d('CompanionRemote: Authentication successful'); + + if (!completer.isCompleted) { + completer.complete(); + } + + final device = RemoteDevice(id: 'host', name: 'Desktop', platform: 'desktop'); + _deviceConnectedController.add(device); + _connectionStateController.add(RemoteSessionStatus.connected); + + // Send device info + sendDeviceInfo(deviceName, platform); + + // Start ping timer + _startPingTimer(); + } else if (messageType == 'authFailed') { + final message = json['message'] as String? ?? 'Authentication failed'; + appLogger.w('CompanionRemote: $message'); + + if (!completer.isCompleted) { + completer.completeError(RemotePeerError(type: RemotePeerErrorType.authFailed, message: message)); + } + + _errorController.add(RemotePeerError(type: RemotePeerErrorType.authFailed, message: message)); + _connectionStateController.add(RemoteSessionStatus.error); + } else { + // Regular command + final command = RemoteCommand.fromJson(json); + appLogger.d('CompanionRemote: Received command: ${command.type}'); + + if (_shouldSendAck(command)) { + _sendAck(command); + } + + _commandReceivedController.add(command); + + if (command.type == RemoteCommandType.ping) { + _sendPong(); + } + } + } catch (e) { + appLogger.e('CompanionRemote: Failed to parse message', error: e); + } + }, + onDone: () { + appLogger.d('CompanionRemote: Connection closed'); + _deviceDisconnectedController.add(null); + _connectionStateController.add(RemoteSessionStatus.disconnected); + _stopPingTimer(); + // Reconnection is handled by CompanionRemoteProvider + }, + onError: (error) { + appLogger.e('CompanionRemote: Connection error', error: error); + + if (!completer.isCompleted) { + completer.completeError(error); + } + + _errorController.add( + RemotePeerError( + type: RemotePeerErrorType.connectionFailed, + message: 'Connection error: $error', + originalError: error, + ), + ); + _connectionStateController.add(RemoteSessionStatus.error); + }, + ); + } catch (e) { + appLogger.e('CompanionRemote: Failed to connect', error: e); + + if (!completer.isCompleted) { + completer.completeError(e); + } + + _errorController.add( + RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'Failed to connect: $e', originalError: e), + ); + } + + return completer.future.timeout( + const Duration(seconds: 15), + onTimeout: () async { + // Clean up channel on timeout + if (_channel != null) { + await _channel!.sink.close(); + _channel = null; + } + throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session'); + }, + ); + } + + void _startPingTimer() { + _stopPingTimer(); + _pingTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (isConnected) { + sendCommand(const RemoteCommand(type: RemoteCommandType.ping)); + } + }); + } + + void _stopPingTimer() { + _pingTimer?.cancel(); + _pingTimer = null; + } + + bool _shouldSendAck(RemoteCommand command) { + return command.type != RemoteCommandType.ping && + command.type != RemoteCommandType.pong && + command.type != RemoteCommandType.ack && + command.type != RemoteCommandType.deviceInfo; + } + + void _sendAck(RemoteCommand command) { + sendCommand(const RemoteCommand(type: RemoteCommandType.ack)); + } + + void _sendPong() { + sendCommand(const RemoteCommand(type: RemoteCommandType.pong)); + } + + void sendDeviceInfo(String deviceName, String platform) { + sendCommand( + RemoteCommand( + type: RemoteCommandType.deviceInfo, + data: {'id': _myPeerId, 'name': deviceName, 'platform': platform, 'role': _role?.name}, + ), + ); + } + + void sendCommand(RemoteCommand command) { + try { + final json = jsonEncode(command.toJson()); + + if (_role == RemoteSessionRole.host && _clientSocket != null) { + _clientSocket!.add(json); + appLogger.d('CompanionRemote: Sent command (host): ${command.type}'); + } else if (_role == RemoteSessionRole.remote && _channel != null) { + _channel!.sink.add(json); + appLogger.d('CompanionRemote: Sent command (remote): ${command.type}'); + } else { + appLogger.w('CompanionRemote: No connection to send command'); + } + } catch (e) { + appLogger.e('CompanionRemote: Failed to send command', error: e); + _errorController.add( + RemotePeerError( + type: RemotePeerErrorType.dataChannelError, + message: 'Failed to send command: $e', + originalError: e, + ), + ); + } + } + + Future disconnect() async { + appLogger.d('CompanionRemote: Disconnecting'); + + _stopPingTimer(); + + if (_clientSocket != null) { + await _clientSocket!.close(); + _clientSocket = null; + } + + if (_channel != null) { + await _channel!.sink.close(); + _channel = null; + } + + if (_server != null) { + await _server!.close(); + _server = null; + } + + _sessionId = null; + _pin = null; + _myPeerId = null; + _hostAddress = null; + _role = null; + + _connectionStateController.add(RemoteSessionStatus.disconnected); + } + + Future dispose() async { + await disconnect(); + await _commandReceivedController.close(); + await _deviceConnectedController.close(); + await _deviceDisconnectedController.close(); + await _errorController.close(); + await _connectionStateController.close(); + } +} diff --git a/lib/services/companion_remote/companion_remote_receiver.dart b/lib/services/companion_remote/companion_remote_receiver.dart new file mode 100644 index 00000000..77a38097 --- /dev/null +++ b/lib/services/companion_remote/companion_remote_receiver.dart @@ -0,0 +1,144 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../../models/companion_remote/remote_command.dart'; +import '../../models/companion_remote/remote_command_type.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/key_event_simulator.dart'; + +class CompanionRemoteReceiver { + CompanionRemoteReceiver._(); + + static CompanionRemoteReceiver? _instance; + + static CompanionRemoteReceiver get instance { + _instance ??= CompanionRemoteReceiver._(); + return _instance!; + } + + /// Called on any remote input so InputModeTracker can switch to keyboard mode. + /// Same pattern as [GamepadService.onGamepadInput]. + static VoidCallback? onRemoteInput; + + VoidCallback? onTabNext; + VoidCallback? onTabPrevious; + VoidCallback? onTabDiscover; + VoidCallback? onTabLibraries; + VoidCallback? onTabSearch; + VoidCallback? onTabDownloads; + VoidCallback? onTabSettings; + VoidCallback? onHome; + void Function(String? query)? onSearchAction; + VoidCallback? onNextTrack; + VoidCallback? onPreviousTrack; + VoidCallback? onStop; + VoidCallback? onSeekForward; + VoidCallback? onSeekBackward; + VoidCallback? onVolumeUp; + VoidCallback? onVolumeDown; + VoidCallback? onVolumeMute; + VoidCallback? onSubtitles; + VoidCallback? onAudioTracks; + VoidCallback? onFullscreen; + + void handleCommand(RemoteCommand command, BuildContext? context) { + appLogger.d('CompanionRemoteReceiver: Handling command: ${command.type}'); + + // Switch to keyboard mode so focus visuals render + onRemoteInput?.call(); + _setTraditionalFocusHighlight(); + scheduleFrameIfIdle(); + + switch (command.type) { + case RemoteCommandType.dpadUp: + simulateKeyPress(LogicalKeyboardKey.arrowUp); + case RemoteCommandType.dpadDown: + simulateKeyPress(LogicalKeyboardKey.arrowDown); + case RemoteCommandType.dpadLeft: + simulateKeyPress(LogicalKeyboardKey.arrowLeft); + case RemoteCommandType.dpadRight: + simulateKeyPress(LogicalKeyboardKey.arrowRight); + case RemoteCommandType.select: + simulateKeyPress(LogicalKeyboardKey.enter); + case RemoteCommandType.back: + simulateKeyPress(LogicalKeyboardKey.escape); + case RemoteCommandType.contextMenu: + simulateKeyPress(LogicalKeyboardKey.contextMenu); + + case RemoteCommandType.play: + simulateKeyPress(LogicalKeyboardKey.space); + case RemoteCommandType.pause: + simulateKeyPress(LogicalKeyboardKey.space); + case RemoteCommandType.playPause: + simulateKeyPress(LogicalKeyboardKey.space); + case RemoteCommandType.seekForward: + onSeekForward?.call(); + case RemoteCommandType.seekBackward: + onSeekBackward?.call(); + + case RemoteCommandType.volumeUp: + onVolumeUp?.call(); + case RemoteCommandType.volumeDown: + onVolumeDown?.call(); + case RemoteCommandType.volumeMute: + onVolumeMute?.call(); + + case RemoteCommandType.tabNext: + onTabNext?.call(); + case RemoteCommandType.tabPrevious: + onTabPrevious?.call(); + case RemoteCommandType.tabDiscover: + onTabDiscover?.call(); + case RemoteCommandType.tabLibraries: + onTabLibraries?.call(); + case RemoteCommandType.tabSearch: + onTabSearch?.call(); + case RemoteCommandType.tabDownloads: + onTabDownloads?.call(); + case RemoteCommandType.tabSettings: + onTabSettings?.call(); + + case RemoteCommandType.home: + onHome?.call(); + case RemoteCommandType.search: + final query = command.data?['query'] as String?; + onSearchAction?.call(query); + + case RemoteCommandType.stop: + onStop?.call(); + case RemoteCommandType.nextTrack: + onNextTrack?.call(); + case RemoteCommandType.previousTrack: + onPreviousTrack?.call(); + + case RemoteCommandType.subtitles: + onSubtitles?.call(); + case RemoteCommandType.audioTracks: + onAudioTracks?.call(); + + case RemoteCommandType.fullscreen: + if (onFullscreen != null) { + onFullscreen!.call(); + } else { + simulateKeyPress(LogicalKeyboardKey.keyF); + } + + case RemoteCommandType.ping: + case RemoteCommandType.pong: + case RemoteCommandType.ack: + case RemoteCommandType.deviceInfo: + case RemoteCommandType.disconnect: + case RemoteCommandType.syncState: + break; + + default: + appLogger.w('CompanionRemoteReceiver: Unhandled command type: ${command.type}'); + } + } + + void _setTraditionalFocusHighlight() { + if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + } + } +} diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 83d776a8..27efd8ea 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:universal_gamepad/universal_gamepad.dart'; import '../utils/app_logger.dart'; +import '../utils/key_event_simulator.dart' as key_sim; /// Service that bridges gamepad input to Flutter's focus navigation system. /// @@ -106,7 +107,7 @@ class GamepadService { if (event.pressed) { onGamepadInput?.call(); _setTraditionalFocusHighlight(); - _scheduleFrameIfIdle(); + key_sim.scheduleFrameIfIdle(); } final wasPressed = _pressedButtons.contains(event.button); @@ -170,7 +171,7 @@ class GamepadService { if (event.value.abs() > 0.3) { onGamepadInput?.call(); _setTraditionalFocusHighlight(); - _scheduleFrameIfIdle(); + SchedulerBinding.instance.ensureVisualUpdate(); } switch (event.axis) { @@ -342,13 +343,4 @@ class GamepadService { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; } } - - // Force a frame when the engine is idle so focus visuals update immediately - // on gamepad input (desktop may not wake up without mouse/keyboard activity). - void _scheduleFrameIfIdle() { - final scheduler = SchedulerBinding.instance; - if (scheduler.schedulerPhase == SchedulerPhase.idle) { - scheduler.scheduleFrame(); - } - } } diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart new file mode 100644 index 00000000..9b187a66 --- /dev/null +++ b/lib/utils/key_event_simulator.dart @@ -0,0 +1,76 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Shared utility for simulating key press events through the focus tree. +/// +/// Used by both [CompanionRemoteReceiver] and [GamepadService] to translate +/// external input (remote commands, gamepad buttons) into focus-tree key events. +void simulateKeyPress(LogicalKeyboardKey logicalKey) { + SchedulerBinding.instance.addPostFrameCallback((_) { + final focusNode = FocusManager.instance.primaryFocus; + if (focusNode == null) return; + + final physicalKey = _getPhysicalKey(logicalKey); + + final keyDownEvent = KeyDownEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + ); + + // Walk up the focus tree dispatching the key event + FocusNode? node = focusNode; + KeyEventResult result = KeyEventResult.ignored; + + while (node != null && result != KeyEventResult.handled) { + if (node.onKeyEvent != null) { + result = node.onKeyEvent!(node, keyDownEvent); + } + node = node.parent; + } + + // Send key up event + final keyUpEvent = KeyUpEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + ); + + node = focusNode; + while (node != null) { + if (node.onKeyEvent != null) { + final upResult = node.onKeyEvent!(node, keyUpEvent); + if (upResult == KeyEventResult.handled) break; + } + node = node.parent; + } + }); +} + +/// Force a frame when the engine is idle so focus visuals update immediately +/// on external input (desktop may not wake up without mouse/keyboard activity). +void scheduleFrameIfIdle() { + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) { + SchedulerBinding.instance.scheduleFrame(); + } +} + +PhysicalKeyboardKey _getPhysicalKey(LogicalKeyboardKey logicalKey) { + if (logicalKey == LogicalKeyboardKey.arrowUp) return PhysicalKeyboardKey.arrowUp; + if (logicalKey == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown; + if (logicalKey == LogicalKeyboardKey.arrowLeft) return PhysicalKeyboardKey.arrowLeft; + if (logicalKey == LogicalKeyboardKey.arrowRight) return PhysicalKeyboardKey.arrowRight; + if (logicalKey == LogicalKeyboardKey.enter) return PhysicalKeyboardKey.enter; + if (logicalKey == LogicalKeyboardKey.escape) return PhysicalKeyboardKey.escape; + if (logicalKey == LogicalKeyboardKey.space) return PhysicalKeyboardKey.space; + if (logicalKey == LogicalKeyboardKey.contextMenu) return PhysicalKeyboardKey.contextMenu; + if (logicalKey == LogicalKeyboardKey.audioVolumeUp) return PhysicalKeyboardKey.audioVolumeUp; + if (logicalKey == LogicalKeyboardKey.audioVolumeDown) return PhysicalKeyboardKey.audioVolumeDown; + if (logicalKey == LogicalKeyboardKey.audioVolumeMute) return PhysicalKeyboardKey.audioVolumeMute; + if (logicalKey == LogicalKeyboardKey.keyF) return PhysicalKeyboardKey.keyF; + if (logicalKey == LogicalKeyboardKey.gameButtonA) return PhysicalKeyboardKey.gameButtonA; + if (logicalKey == LogicalKeyboardKey.gameButtonB) return PhysicalKeyboardKey.gameButtonB; + if (logicalKey == LogicalKeyboardKey.gameButtonX) return PhysicalKeyboardKey.gameButtonX; + return PhysicalKeyboardKey.enter; +} diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart new file mode 100644 index 00000000..59c22497 --- /dev/null +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../providers/companion_remote_provider.dart'; +import '../../utils/app_logger.dart'; + +class RemoteSessionDialog extends StatefulWidget { + const RemoteSessionDialog({super.key}); + + @override + State createState() => _RemoteSessionDialogState(); + + static Future show(BuildContext context) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const RemoteSessionDialog(), + ); + } +} + +class _RemoteSessionDialogState extends State { + bool _isCreatingSession = false; + String? _errorMessage; + String? _hostAddress; // Format: "ip:port" + + @override + void initState() { + super.initState(); + // Defer to avoid notifyListeners() during build phase + WidgetsBinding.instance.addPostFrameCallback((_) => _createSession()); + } + + Future _createSession() async { + setState(() { + _isCreatingSession = true; + _errorMessage = null; + _hostAddress = null; + }); + + try { + final provider = context.read(); + final result = await provider.createSession(); + + setState(() { + _isCreatingSession = false; + _hostAddress = result.address; + }); + } catch (e) { + appLogger.e('Failed to create companion remote session', error: e); + setState(() { + _isCreatingSession = false; + _errorMessage = e.toString(); + }); + } + } + + void _copyToClipboard(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$label copied to clipboard'), duration: const Duration(seconds: 2))); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + if (_isCreatingSession) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text('Creating remote session...', style: Theme.of(context).textTheme.titleMedium), + ], + ), + ), + ); + } + + if (_errorMessage != null) { + return AlertDialog( + title: const Text('Error'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Failed to create remote session:'), + const SizedBox(height: 8), + Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')), + TextButton(onPressed: _createSession, child: const Text('Retry')), + ], + ); + } + + final session = provider.session; + if (session == null || _hostAddress == null) { + return AlertDialog( + title: const Text('Error'), + content: const Text('No session available'), + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close'))], + ); + } + + // Parse IP and port from hostAddress + final addressParts = _hostAddress!.split(':'); + final ip = addressParts[0]; + final port = addressParts[1]; + + // New QR format: ip|port|sessionId|pin (using pipe separator) + final qrData = '$ip|$port|${session.sessionId}|${session.pin}'; + + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.phone_android, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Companion Remote', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 4), + Text( + session.connectedDevice != null + ? 'Connected to ${session.connectedDevice!.name}' + : 'Waiting for connection...', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: session.connectedDevice != null + ? Colors.green + : Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ], + ), + ), + IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()), + ], + ), + const SizedBox(height: 24), + if (session.connectedDevice == null) ...[ + Text('Scan QR Code', style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center), + const SizedBox(height: 16), + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: QrImageView( + data: qrData, + version: QrVersions.auto, + size: 200, + backgroundColor: Colors.white, + ), + ), + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + Text( + 'Or enter manually', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + _buildCodeCard( + context, + 'Host Address', + _hostAddress!, + onCopy: () => _copyToClipboard(_hostAddress!, 'Host Address'), + ), + const SizedBox(height: 12), + _buildCodeCard( + context, + 'Session ID', + session.sessionId, + onCopy: () => _copyToClipboard(session.sessionId, 'Session ID'), + ), + const SizedBox(height: 12), + _buildCodeCard(context, 'PIN', session.pin, onCopy: () => _copyToClipboard(session.pin, 'PIN')), + ] else ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 48), + const SizedBox(height: 8), + Text('Connected', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text(session.connectedDevice!.name, style: Theme.of(context).textTheme.bodyLarge), + Text(session.connectedDevice!.platform, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Use your mobile device to control this app', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (session.connectedDevice != null) + TextButton.icon( + onPressed: () async { + await provider.leaveSession(); + await _createSession(); + }, + icon: const Icon(Icons.refresh), + label: const Text('New Session'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () async { + await provider.leaveSession(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('Disconnect'), + ), + const SizedBox(width: 8), + FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Minimize')), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildCodeCard(BuildContext context, String label, String code, {VoidCallback? onCopy}) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 4), + Text( + code, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontFamily: 'monospace', letterSpacing: 2), + ), + ], + ), + ), + IconButton(icon: const Icon(Icons.copy), onPressed: onCopy, tooltip: 'Copy to clipboard'), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 81ff4678..ab6b483f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -258,6 +258,7 @@ class _PlexVideoControlsState extends State with WindowListen _initAlwaysOnTopState(); } + // Focus play/pause button on first frame if in keyboard mode WidgetsBinding.instance.addPostFrameCallback((_) { _focusPlayPauseIfKeyboardMode(); @@ -733,7 +734,12 @@ class _PlexVideoControlsState extends State with WindowListen } void _updateTrafficLightVisibility() async { - await MacOSWindowService.setTrafficLightsVisible(_showControls); + // When maximized or fullscreen, always keep traffic lights visible so the + // user can reach them without the controls-hide-on-mouse-leave race. + // In normal windowed mode, toggle with controls as before. + final isMaximizedOrFullscreen = await windowManager.isMaximized() || await windowManager.isFullScreen(); + final visible = isMaximizedOrFullscreen ? true : _showControls; + await MacOSWindowService.setTrafficLightsVisible(visible); } /// Check whether PiP is supported on this device diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9ec69bfa..718fe9a5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,8 +9,10 @@ import connectivity_plus import device_info_plus import file_picker import in_app_review +import mobile_scanner import os_media_controls import package_info_plus +import path_provider_foundation import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin @@ -25,8 +27,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ade972c6..d0b49723 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -8,10 +8,15 @@ PODS: - FlutterMacOS (1.0.0) - in_app_review (2.0.0): - FlutterMacOS + - mobile_scanner (6.0.2): + - FlutterMacOS - os_media_controls (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): @@ -60,8 +65,10 @@ DEPENDENCIES: - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) + - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) - os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) @@ -86,10 +93,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral in_app_review: :path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos + mobile_scanner: + :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos os_media_controls: :path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos shared_preferences_foundation: @@ -113,8 +124,10 @@ SPEC CHECKSUMS: file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4 + mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3 os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/pubspec.lock b/pubspec.lock index c9b3df52..0a9efc48 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "7.7.1" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: "825071d553c4aef2252196d46a665fbd8e0cb06de07725f25d1b29bd18d65fff" + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "0.13.4" ansicolor: dependency: transitive description: @@ -69,18 +69,18 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "2.5.4" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.1.2" build_daemon: dependency: transitive description: @@ -89,14 +89,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" built_collection: dependency: transitive description: @@ -109,10 +125,10 @@ packages: dependency: transitive description: name: built_value - sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d url: "https://pub.dev" source: hosted - version: "8.12.3" + version: "8.12.0" cached_network_image: dependency: "direct main" description: @@ -137,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + chalkdart: + dependency: transitive + description: + name: chalkdart + sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86" + url: "https://pub.dev" + source: hosted + version: "3.0.5" characters: dependency: transitive description: @@ -177,22 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "4.11.0" collection: dependency: transitive description: @@ -229,10 +245,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+1" crypto: dependency: "direct main" description: @@ -261,10 +277,10 @@ packages: dependency: "direct dev" description: name: dart_code_linter - sha256: "1b53722d9933a5f5d4580acc29c7f16b1fde66d21d1ecf7bb2a811caf3a42b42" + sha256: "9456e0a7508b0d76be301dc2f73be37e601019e85c5f59e6a5cc460af90f7dcd" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.1.1" dart_discord_presence: dependency: "direct main" description: @@ -277,18 +293,18 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.1" dbus: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" device_info_plus: dependency: "direct main" description: @@ -309,10 +325,10 @@ packages: dependency: "direct main" description: name: dio - sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 url: "https://pub.dev" source: hosted - version: "5.9.1" + version: "5.9.0" dio_web_adapter: dependency: transitive description: @@ -325,18 +341,18 @@ packages: dependency: "direct main" description: name: drift - sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" url: "https://pub.dev" source: hosted - version: "2.31.0" + version: "2.28.2" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" + sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" url: "https://pub.dev" source: hosted - version: "2.31.0" + version: "2.28.0" duration: dependency: "direct main" description: @@ -357,10 +373,10 @@ packages: dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.4" file: dependency: transitive description: @@ -389,18 +405,18 @@ packages: dependency: "direct main" description: name: flex_color_picker - sha256: a0979dd61f21b634717b98eb4ceaed2bfe009fe020ce8597aaf164b9eeb57aaa + sha256: f5b0b53d4ae0d59b1e28dfc21d5398e5028cf8e764518e491a52fd050aa23881 url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.7.2" flex_seed_scheme: dependency: transitive description: name: flex_seed_scheme - sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7 + sha256: "828291a5a4d4283590541519d8b57821946660ac61d2e07d955f81cfcab22e5d" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "3.6.1" flutter: dependency: "direct main" description: flutter @@ -461,6 +477,14 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" glob: dependency: transitive description: @@ -477,14 +501,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" html: dependency: transitive description: @@ -521,10 +537,10 @@ packages: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.5.4" in_app_review: dependency: "direct main" description: @@ -557,6 +573,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -569,10 +593,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c url: "https://pub.dev" source: hosted - version: "6.11.2" + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -641,10 +665,10 @@ packages: dependency: "direct main" description: name: material_symbols_icons - sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 + sha256: "02555a48e1ec02b16e532dfd4ef13c4f6bf7ec7c20230e58e56641a393433dc3" url: "https://pub.dev" source: hosted - version: "4.2906.0" + version: "4.2892.0" meta: dependency: transitive description: @@ -661,14 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - native_toolchain_c: - dependency: transitive + mobile_scanner: + dependency: "direct main" description: - name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + name: mobile_scanner + sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173" url: "https://pub.dev" source: hosted - version: "0.17.4" + version: "6.0.11" nested: dependency: transitive description: @@ -685,14 +709,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" octo_image: dependency: transitive description: @@ -762,18 +778,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.20" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.4.3" path_provider_linux: dependency: transitive description: @@ -914,10 +930,10 @@ packages: dependency: transitive description: name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" url: "https://pub.dev" source: hosted - version: "0.28.0" + version: "0.27.7" saf_stream: dependency: "direct main" description: @@ -978,26 +994,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" url: "https://pub.dev" source: hosted - version: "2.4.20" + version: "2.4.15" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.5" shared_preferences_linux: dependency: transitive description: @@ -1055,50 +1071,50 @@ packages: dependency: "direct main" description: name: slang - sha256: "81e277dc5e2305f53412b92afeb803620453259afe147d5cd6417700f998a7a6" + sha256: "13e3b6f07adc51ab751e7889647774d294cbce7a3382f81d9e5029acfe9c37b2" url: "https://pub.dev" source: hosted - version: "4.12.1" + version: "4.12.0" slang_build_runner: dependency: "direct dev" description: name: slang_build_runner - sha256: "010f337703dbc736ac9f0e3eb9d6e1464850736fed69f85a23c07ee4678fcc1c" + sha256: "453d74b5430153a3c4150d5ba8f6380e0785f3939f7511f10ac5b6cf9bb7d2a7" url: "https://pub.dev" source: hosted - version: "4.12.1" + version: "4.12.0" slang_flutter: dependency: "direct main" description: name: slang_flutter - sha256: "0f0276c400660c8b67150005aa4df57643b86ce6ae9c824abee8e25f345d9abc" + sha256: "0a4545cca5404d6b7487cf61cf1fe56c52daeb08de56a7574ee8381fbad035a0" url: "https://pub.dev" source: hosted - version: "4.12.1" + version: "4.12.0" source_gen: dependency: transitive description: name: source_gen - sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "2.0.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.7" source_span: dependency: transitive description: name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.2" + version: "1.10.1" sqflite: dependency: transitive description: @@ -1159,10 +1175,10 @@ packages: dependency: transitive description: name: sqlparser - sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" + sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" url: "https://pub.dev" source: hosted - version: "0.43.1" + version: "0.41.2" stack_trace: dependency: transitive description: @@ -1219,6 +1235,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -1247,34 +1271,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.24" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.3.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.4" url_launcher_platform_interface: dependency: transitive description: @@ -1287,18 +1311,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.4" uuid: dependency: "direct main" description: @@ -1367,10 +1391,10 @@ packages: dependency: transitive description: name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.1.4" web: dependency: transitive description: @@ -1477,4 +1501,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.7 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 4d087375..39a4a1f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: in_app_review: ^2.0.11 dart_discord_presence: ^1.1.0 flutter_svg: ^2.2.3 + mobile_scanner: ^6.0.2 dev_dependencies: flutter_test: