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/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..aeef05cd 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(); } 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/remote_command.dart b/lib/models/companion_remote/remote_command.dart new file mode 100644 index 00000000..aab9879a --- /dev/null +++ b/lib/models/companion_remote/remote_command.dart @@ -0,0 +1,80 @@ +import 'remote_command_type.dart'; + +class RemoteCommand { + final RemoteCommandType type; + final String deviceId; + final String deviceName; + final DateTime timestamp; + final Map? data; + + RemoteCommand({ + required this.type, + required this.deviceId, + required this.deviceName, + DateTime? timestamp, + this.data, + }) : timestamp = timestamp ?? DateTime.now(); + + Map toJson() { + return { + 'type': type.name, + 'deviceId': deviceId, + 'deviceName': deviceName, + 'timestamp': timestamp.toIso8601String(), + if (data != null) 'data': data, + }; + } + + factory RemoteCommand.fromJson(Map json) { + return RemoteCommand( + type: RemoteCommandType.values.firstWhere( + (e) => e.name == json['type'], + orElse: () => RemoteCommandType.ping, + ), + deviceId: json['deviceId'] as String, + deviceName: json['deviceName'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + data: json['data'] as Map?, + ); + } + + RemoteCommand copyWith({ + RemoteCommandType? type, + String? deviceId, + String? deviceName, + DateTime? timestamp, + Map? data, + }) { + return RemoteCommand( + type: type ?? this.type, + deviceId: deviceId ?? this.deviceId, + deviceName: deviceName ?? this.deviceName, + timestamp: timestamp ?? this.timestamp, + data: data ?? this.data, + ); + } + + @override + String toString() { + return 'RemoteCommand(type: ${type.name}, device: $deviceName, data: $data)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is RemoteCommand && + other.type == type && + other.deviceId == deviceId && + other.deviceName == deviceName && + other.timestamp == timestamp; + } + + @override + int get hashCode { + return type.hashCode ^ + deviceId.hashCode ^ + deviceName.hashCode ^ + timestamp.hashCode; + } +} 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..ca5febb1 --- /dev/null +++ b/lib/models/companion_remote/remote_command_type.dart @@ -0,0 +1,191 @@ +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, + capabilitiesRequest, + capabilitiesResponse, + disconnect, + ack, // Acknowledgment of received command +} + +extension RemoteCommandTypeExtension on RemoteCommandType { + String get displayName { + switch (this) { + case RemoteCommandType.dpadUp: + return 'Up'; + case RemoteCommandType.dpadDown: + return 'Down'; + case RemoteCommandType.dpadLeft: + return 'Left'; + case RemoteCommandType.dpadRight: + return 'Right'; + case RemoteCommandType.select: + return 'Select'; + case RemoteCommandType.back: + return 'Back'; + case RemoteCommandType.contextMenu: + return 'Menu'; + case RemoteCommandType.play: + return 'Play'; + case RemoteCommandType.pause: + return 'Pause'; + case RemoteCommandType.playPause: + return 'Play/Pause'; + case RemoteCommandType.stop: + return 'Stop'; + case RemoteCommandType.seekForward: + return 'Seek Forward'; + case RemoteCommandType.seekBackward: + return 'Seek Backward'; + case RemoteCommandType.nextTrack: + return 'Next'; + case RemoteCommandType.previousTrack: + return 'Previous'; + case RemoteCommandType.skipIntro: + return 'Skip Intro'; + case RemoteCommandType.skipCredits: + return 'Skip Credits'; + case RemoteCommandType.volumeUp: + return 'Volume Up'; + case RemoteCommandType.volumeDown: + return 'Volume Down'; + case RemoteCommandType.volumeMute: + return 'Mute'; + case RemoteCommandType.volumeSet: + return 'Set Volume'; + case RemoteCommandType.tabNext: + return 'Next Tab'; + case RemoteCommandType.tabPrevious: + return 'Previous Tab'; + case RemoteCommandType.tabDiscover: + return 'Discover'; + case RemoteCommandType.tabLibraries: + return 'Libraries'; + case RemoteCommandType.tabSearch: + return 'Search'; + case RemoteCommandType.tabDownloads: + return 'Downloads'; + case RemoteCommandType.tabSettings: + return 'Settings'; + case RemoteCommandType.home: + return 'Home'; + case RemoteCommandType.search: + return 'Search'; + case RemoteCommandType.subtitles: + return 'Subtitles'; + case RemoteCommandType.audioTracks: + return 'Audio'; + case RemoteCommandType.qualitySettings: + return 'Quality'; + case RemoteCommandType.fullscreen: + return 'Fullscreen'; + case RemoteCommandType.ping: + return 'Ping'; + case RemoteCommandType.pong: + return 'Pong'; + case RemoteCommandType.deviceInfo: + return 'Device Info'; + case RemoteCommandType.capabilitiesRequest: + return 'Capabilities Request'; + case RemoteCommandType.capabilitiesResponse: + return 'Capabilities Response'; + case RemoteCommandType.disconnect: + return 'Disconnect'; + case RemoteCommandType.ack: + return 'Acknowledgment'; + } + } + + bool get isNavigationCommand { + return [ + RemoteCommandType.dpadUp, + RemoteCommandType.dpadDown, + RemoteCommandType.dpadLeft, + RemoteCommandType.dpadRight, + RemoteCommandType.select, + RemoteCommandType.back, + RemoteCommandType.contextMenu, + ].contains(this); + } + + bool get isPlaybackCommand { + return [ + RemoteCommandType.play, + RemoteCommandType.pause, + RemoteCommandType.playPause, + RemoteCommandType.stop, + RemoteCommandType.seekForward, + RemoteCommandType.seekBackward, + RemoteCommandType.nextTrack, + RemoteCommandType.previousTrack, + RemoteCommandType.skipIntro, + RemoteCommandType.skipCredits, + ].contains(this); + } + + bool get isVolumeCommand { + return [ + RemoteCommandType.volumeUp, + RemoteCommandType.volumeDown, + RemoteCommandType.volumeMute, + RemoteCommandType.volumeSet, + ].contains(this); + } + + bool get isTabCommand { + return [ + RemoteCommandType.tabNext, + RemoteCommandType.tabPrevious, + RemoteCommandType.tabDiscover, + RemoteCommandType.tabLibraries, + RemoteCommandType.tabSearch, + RemoteCommandType.tabDownloads, + RemoteCommandType.tabSettings, + ].contains(this); + } +} diff --git a/lib/models/companion_remote/remote_session.dart b/lib/models/companion_remote/remote_session.dart new file mode 100644 index 00000000..012e92df --- /dev/null +++ b/lib/models/companion_remote/remote_session.dart @@ -0,0 +1,151 @@ +enum RemoteSessionRole { + host, + remote, +} + +enum RemoteSessionStatus { + disconnected, + connecting, + connected, + reconnecting, + error, +} + +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 ?? {}; + + Map toJson() { + return { + 'id': id, + 'name': name, + 'platform': platform, + 'connectedAt': connectedAt.toIso8601String(), + 'capabilities': capabilities, + }; + } + + factory RemoteDevice.fromJson(Map json) { + return RemoteDevice( + id: json['id'] as String, + name: json['name'] as String, + platform: json['platform'] as String, + connectedAt: DateTime.parse(json['connectedAt'] as String), + capabilities: Map.from(json['capabilities'] as Map? ?? {}), + ); + } + + 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; +} + +class RemoteSession { + final String sessionId; + final String pin; + final RemoteSessionRole role; + 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; + + RemoteSession copyWith({ + String? sessionId, + String? pin, + RemoteSessionRole? role, + RemoteSessionStatus? status, + RemoteDevice? connectedDevice, + DateTime? createdAt, + String? errorMessage, + }) { + return RemoteSession( + sessionId: sessionId ?? this.sessionId, + pin: pin ?? this.pin, + role: role ?? this.role, + status: status ?? this.status, + connectedDevice: connectedDevice ?? this.connectedDevice, + createdAt: createdAt ?? this.createdAt, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + Map toJson() { + return { + 'sessionId': sessionId, + 'pin': pin, + 'role': role.name, + 'status': status.name, + 'connectedDevice': connectedDevice?.toJson(), + 'createdAt': createdAt.toIso8601String(), + 'errorMessage': errorMessage, + }; + } + + factory RemoteSession.fromJson(Map json) { + return RemoteSession( + sessionId: json['sessionId'] as String, + pin: json['pin'] as String, + role: RemoteSessionRole.values.firstWhere( + (e) => e.name == json['role'], + orElse: () => RemoteSessionRole.remote, + ), + status: RemoteSessionStatus.values.firstWhere( + (e) => e.name == json['status'], + orElse: () => RemoteSessionStatus.disconnected, + ), + connectedDevice: json['connectedDevice'] != null + ? RemoteDevice.fromJson(json['connectedDevice'] as Map) + : null, + createdAt: DateTime.parse(json['createdAt'] as String), + errorMessage: json['errorMessage'] as String?, + ); + } +} diff --git a/lib/models/companion_remote/trusted_device.dart b/lib/models/companion_remote/trusted_device.dart new file mode 100644 index 00000000..35c8a9a3 --- /dev/null +++ b/lib/models/companion_remote/trusted_device.dart @@ -0,0 +1,67 @@ +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(); + + Map toJson() { + return { + 'peerId': peerId, + 'deviceName': deviceName, + 'platform': platform, + 'firstConnected': firstConnected.toIso8601String(), + 'lastConnected': lastConnected.toIso8601String(), + 'isApproved': isApproved, + }; + } + + factory TrustedDevice.fromJson(Map json) { + return TrustedDevice( + peerId: json['peerId'] as String, + deviceName: json['deviceName'] as String, + platform: json['platform'] as String, + firstConnected: DateTime.parse(json['firstConnected'] as String), + lastConnected: DateTime.parse(json['lastConnected'] as String), + isApproved: json['isApproved'] as bool? ?? false, + ); + } + + 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/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart new file mode 100644 index 00000000..98c4b4dd --- /dev/null +++ b/lib/providers/companion_remote_provider.dart @@ -0,0 +1,542 @@ +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 '../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 = []; + + 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; + + int get reconnectAttempts => _reconnectAttempts; + + StreamSubscription? _commandSubscription; + StreamSubscription? _deviceConnectedSubscription; + StreamSubscription? _deviceDisconnectedSubscription; + StreamSubscription? _errorSubscription; + StreamSubscription? _statusSubscription; + + 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); + + 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.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, + connectedDevice: null, + ); + notifyListeners(); + } 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 platform = command.data!['platform'] as String? ?? 'unknown'; + final role = command.data!['role'] as String?; + + appLogger.d('CompanionRemote: Device info - name: ${command.deviceName}, platform: $platform, role: $role'); + + final device = RemoteDevice( + id: command.deviceId, + name: command.deviceName, + 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 _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})> 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}'); + + 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) async { + await leaveSession(); + + _lastSessionId = sessionId; + _lastPin = pin; + + appLogger.d('CompanionRemote: Joining session - ID: $sessionId'); + + _peerService = CompanionRemotePeerService(); + _setupPeerServiceListeners(); + + _session = RemoteSession( + sessionId: sessionId, + pin: pin, + role: RemoteSessionRole.remote, + status: RemoteSessionStatus.connecting, + ); + notifyListeners(); + + try { + await _peerService!.joinSession(sessionId, pin, _deviceName, _platform); + + _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'); + final command = RemoteCommand( + type: type, + deviceId: _peerService!.myPeerId ?? 'unknown', + deviceName: _deviceName, + data: data, + ); + + _peerService!.sendCommand(command); + } + + 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) { + 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); + + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + _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, + connectedDevice: null, + ); + 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; + _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 { + _discoveryService = CompanionRemoteDiscoveryService(); + + // Listen for recent sessions updates + _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(), + ); + + if (_discoveryService != null) { + await _discoveryService!.addRecentSession(recentSession); + } + } + + /// Connect to a recent session + Future connectToRecentSession(RecentRemoteSession session) async { + await joinSession(session.sessionId, session.pin); + } + + /// 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(); + _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..7afe049a --- /dev/null +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -0,0 +1,731 @@ +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(); + print('🔴 MobileRemoteScreen: Button pressed! Type: $type'); + appLogger.d('MobileRemoteScreen: Sending command: $type'); + final provider = context.read(); + print('🔴 Provider isConnected: ${provider.isConnected}'); + provider.sendCommand(type); + print('🔴 sendCommand called'); + } + + @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( + segments: const [ + ButtonSegment(value: 0, label: Text('Navigate'), icon: Icon(Icons.navigation)), + ButtonSegment(value: 1, label: Text('Playback'), icon: Icon(Icons.play_arrow)), + ButtonSegment(value: 2, label: Text('Quick'), 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() { + 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), + ), + 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() { + return Column( + children: [ + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + _RemoteCard( + icon: Icons.search, + label: 'Search', + onPressed: _showSearchSheet, + ), + _RemoteCard( + icon: Icons.fullscreen, + label: 'Fullscreen', + onPressed: () => _sendCommand(RemoteCommandType.fullscreen), + ), + ], + ), + ], + ); + } +} + +class _DPad extends StatelessWidget { + final Function(RemoteCommandType) onCommand; + + const _DPad({required this.onCommand}); + + @override + Widget build(BuildContext context) { + const size = 80.0; + const centerSize = 60.0; + + return SizedBox( + width: size * 3, + height: size * 3, + child: Stack( + children: [ + Positioned( + left: size, + top: 0, + child: _DPadButton( + icon: Icons.arrow_drop_up, + onPressed: () => onCommand(RemoteCommandType.dpadUp), + size: size, + ), + ), + Positioned( + left: size, + bottom: 0, + child: _DPadButton( + icon: Icons.arrow_drop_down, + onPressed: () => onCommand(RemoteCommandType.dpadDown), + size: size, + ), + ), + Positioned( + left: 0, + top: size, + child: _DPadButton( + icon: Icons.arrow_left, + onPressed: () => onCommand(RemoteCommandType.dpadLeft), + size: size, + ), + ), + Positioned( + right: 0, + top: size, + child: _DPadButton( + icon: Icons.arrow_right, + onPressed: () => onCommand(RemoteCommandType.dpadRight), + size: size, + ), + ), + Positioned( + left: (size * 3 - centerSize) / 2, + top: (size * 3 - centerSize) / 2, + child: _DPadButton( + icon: Icons.check, + label: 'OK', + onPressed: () => onCommand(RemoteCommandType.select), + size: centerSize, + 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..ca4d92ae --- /dev/null +++ b/lib/screens/companion_remote/pairing_screen.dart @@ -0,0 +1,653 @@ +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 '../../services/companion_remote/companion_remote_discovery_service.dart'; +import '../../utils/app_logger.dart'; + +class PairingScreen extends StatefulWidget { + const PairingScreen({super.key}); + + @override + State createState() => _PairingScreenState(); +} + +class _PairingScreenState extends State { + 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() { + _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(), + ); + + 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; + + final parts = data.split(':'); + if (parts.length == 2) { + _scannerController?.stop(); + setState(() { + _errorMessage = null; + _isConnecting = true; + }); + // Connect directly instead of going through _connect() which requires Form validation + _connectWithCredentials(parts[0], parts[1]); + } else { + setState(() { + _errorMessage = 'Invalid QR code format'; + }); + } + } + + Future _connectWithCredentials(String sessionId, String pin) async { + try { + final provider = context.read(); + await provider.joinSession(sessionId.trim().toUpperCase(), pin.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: _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..c2c326fc 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,60 @@ 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 +1369,15 @@ 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..14f4de84 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,54 @@ 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 1ccd18ce..52454e3e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -22,6 +22,7 @@ import '../models/plex_media_info.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/discord_rpc_service.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; @@ -38,6 +39,7 @@ 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'; @@ -205,6 +207,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(); } @@ -1125,6 +1130,64 @@ 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); + }; + } + + 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; + } + /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton() async { @@ -1187,6 +1250,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..cb3bc716 --- /dev/null +++ b/lib/services/companion_remote/companion_remote_discovery_service.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../services/storage_service.dart'; +import '../../utils/app_logger.dart'; + +/// Recent Companion Remote session for quick reconnection +class RecentRemoteSession { + final String sessionId; + final String pin; + final String deviceName; + final String platform; + final DateTime lastConnected; + + RecentRemoteSession({ + required this.sessionId, + required this.pin, + required this.deviceName, + required this.platform, + required this.lastConnected, + }); + + factory RecentRemoteSession.fromJson(Map json) { + return 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), + ); + } + + Map toJson() { + return { + 'sessionId': sessionId, + 'pin': pin, + 'deviceName': deviceName, + 'platform': platform, + 'lastConnected': lastConnected.toIso8601String(), + }; + } + + /// Create from QR code data (format: "sessionId:pin:deviceName:platform") + factory RecentRemoteSession.fromQrData(String qrData) { + final parts = qrData.split(':'); + if (parts.length < 2) { + throw FormatException('Invalid QR code format'); + } + + return RecentRemoteSession( + sessionId: parts[0], + pin: parts[1], + deviceName: parts.length > 2 ? parts[2] : 'Unknown Device', + platform: parts.length > 3 ? parts[3] : 'unknown', + lastConnected: DateTime.now(), + ); + } + + @override + String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}'; +} + +/// 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..442561e6 --- /dev/null +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -0,0 +1,435 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:peerdart/peerdart.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, + 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 { + Peer? _peer; + DataConnection? _connection; + String? _sessionId; + String? _pin; + String? _myPeerId; + RemoteSessionRole? _role; + + final _commandReceivedController = StreamController.broadcast(); + final _deviceConnectedController = StreamController.broadcast(); + final _deviceDisconnectedController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _connectionStateController = StreamController.broadcast(); + + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 3; + Timer? _reconnectTimer; + Timer? _pingTimer; + + 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; + RemoteSessionRole? get role => _role; + bool get isHost => _role == RemoteSessionRole.host; + bool get isConnected => _connection != 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(); + } + + void _attachCommonPeerListeners({ + required Completer completer, + required RemotePeerErrorType errorType, + required String errorMessage, + }) { + _peer!.on('disconnected').listen((_) { + appLogger.w('CompanionRemote: Peer disconnected from server'); + _handleDisconnectedFromServer(); + }); + + _peer!.on('close').listen((_) { + appLogger.d('CompanionRemote: Peer closed'); + _connectionStateController.add(RemoteSessionStatus.disconnected); + }); + + _peer!.on('error').listen((error) { + appLogger.e('CompanionRemote: Peer error', error: error); + _errorController.add( + RemotePeerError( + type: errorType, + message: '$errorMessage: $error', + originalError: error, + ), + ); + if (!completer.isCompleted) { + completer.completeError(error); + } + }); + } + + Future<({String sessionId, String pin})> createSession(String deviceName, String platform) async { + if (_peer != null) { + await disconnect(); + } + + _role = RemoteSessionRole.host; + _sessionId = _generateSessionId(); + _pin = _generatePin(); + _reconnectAttempts = 0; + + final completer = Completer<({String sessionId, String pin})>(); + + try { + _peer = Peer(id: 'cr-$_sessionId-$_pin'); + + _peer!.on('open').listen((id) { + _myPeerId = id as String; + appLogger.d('CompanionRemote: Host peer opened with ID: $_myPeerId'); + _connectionStateController.add(RemoteSessionStatus.connected); + if (!completer.isCompleted) { + completer.complete((sessionId: _sessionId!, pin: _pin!)); + } + }); + + _peer!.on('connection').listen((conn) { + final dataConn = conn as DataConnection; + _handleNewConnection(dataConn, deviceName, platform); + }); + + _attachCommonPeerListeners( + completer: completer, + errorType: RemotePeerErrorType.serverError, + errorMessage: 'Server error', + ); + } catch (e) { + appLogger.e('CompanionRemote: Failed to create peer', error: e); + if (!completer.isCompleted) { + completer.completeError(e); + } + } + + return completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + throw RemotePeerError( + type: RemotePeerErrorType.timeout, + message: 'Timed out creating session', + ); + }, + ); + } + + Future joinSession( + String sessionId, + String pin, + String deviceName, + String platform, + ) async { + if (_peer != null) { + await disconnect(); + } + + _role = RemoteSessionRole.remote; + _sessionId = sessionId.toUpperCase(); + _pin = pin; + _reconnectAttempts = 0; + + final completer = Completer(); + + try { + _peer = Peer(); + + _peer!.on('open').listen((id) { + _myPeerId = id as String; + appLogger.d('CompanionRemote: Remote peer opened with ID: $_myPeerId'); + + final hostPeerId = 'cr-$_sessionId-$_pin'; + appLogger.d('CompanionRemote: Connecting to host: $hostPeerId'); + + _connectionStateController.add(RemoteSessionStatus.connecting); + + final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true)); + _handleNewConnection(conn, deviceName, platform, isOutgoing: true, completer: completer); + }); + + _attachCommonPeerListeners( + completer: completer, + errorType: RemotePeerErrorType.connectionFailed, + errorMessage: 'Failed to connect to session', + ); + } catch (e) { + appLogger.e('CompanionRemote: Failed to create peer for joining', error: e); + if (!completer.isCompleted) { + completer.completeError(e); + } + } + + return completer.future.timeout( + const Duration(seconds: 15), + onTimeout: () { + throw RemotePeerError( + type: RemotePeerErrorType.timeout, + message: 'Timed out joining session', + ); + }, + ); + } + + void _handleNewConnection( + DataConnection conn, + String deviceName, + String platform, { + bool isOutgoing = false, + Completer? completer, + }) { + final peerId = conn.peer; + appLogger.d('CompanionRemote: New connection ${isOutgoing ? "to" : "from"}: $peerId'); + + conn.on('open').listen((_) { + appLogger.d('CompanionRemote: Data channel opened with: $peerId'); + _connection = conn; + _connectionStateController.add(RemoteSessionStatus.connected); + + final device = RemoteDevice( + id: peerId, + name: deviceName, + platform: platform, + ); + + _deviceConnectedController.add(device); + + _startPingTimer(); + + sendDeviceInfo(deviceName, platform); + + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + }); + + conn.on('data').listen((data) { + print('🟡 PeerService: DATA RECEIVED! data type: ${data.runtimeType}'); + try { + print('🟡 Parsing as JSON...'); + final json = data as Map; + print('🟡 Creating RemoteCommand from JSON...'); + final command = RemoteCommand.fromJson(json); + print('🟡 Command received: ${command.type} from $peerId'); + appLogger.d('CompanionRemote: Received command: ${command.type} from $peerId'); + + // Send acknowledgment for non-ping/pong/ack commands + if (command.type != RemoteCommandType.ping && + command.type != RemoteCommandType.pong && + command.type != RemoteCommandType.ack && + command.type != RemoteCommandType.deviceInfo) { + print('🟡 Sending ACK for ${command.type}...'); + final ackCommand = RemoteCommand( + type: RemoteCommandType.ack, + deviceId: _myPeerId ?? 'unknown', + deviceName: deviceName, + data: {'originalCommand': command.type.toString()}, + ); + _connection?.send(ackCommand.toJson()); + print('🟡 ACK sent!'); + } + + print('🟡 Adding to _commandReceivedController...'); + _commandReceivedController.add(command); + print('🟡 Command added to stream!'); + + if (command.type == RemoteCommandType.ping) { + print('🟡 Responding to ping with pong...'); + _sendPong(deviceName, platform); + } else if (command.type == RemoteCommandType.ack) { + print('✅ RECEIVED ACK from desktop for: ${json['data']?['originalCommand']}'); + } + } catch (e) { + print('🔴 PeerService: Failed to parse command: $e'); + appLogger.e('CompanionRemote: Failed to parse command', error: e); + } + }); + + conn.on('close').listen((_) { + appLogger.d('CompanionRemote: Connection closed with: $peerId'); + _connection = null; + _deviceDisconnectedController.add(null); + _connectionStateController.add(RemoteSessionStatus.disconnected); + _stopPingTimer(); + }); + + conn.on('error').listen((error) { + appLogger.e('CompanionRemote: Connection error with $peerId', error: error); + _errorController.add( + RemotePeerError( + type: RemotePeerErrorType.dataChannelError, + message: 'Connection error with peer: $error', + originalError: error, + ), + ); + _connectionStateController.add(RemoteSessionStatus.error); + }); + } + + void _handleDisconnectedFromServer() { + if (_reconnectAttempts < _maxReconnectAttempts) { + _reconnectAttempts++; + final delay = Duration(seconds: _reconnectAttempts * 2); + + appLogger.d( + 'CompanionRemote: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s', + ); + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(delay, () { + _peer?.reconnect(); + }); + } else { + appLogger.e('CompanionRemote: Max reconnect attempts reached'); + _errorController.add( + const RemotePeerError( + type: RemotePeerErrorType.connectionFailed, + message: 'Lost connection to server after multiple reconnect attempts', + ), + ); + _connectionStateController.add(RemoteSessionStatus.error); + } + } + + void _startPingTimer() { + _stopPingTimer(); + _pingTimer = Timer.periodic(const Duration(seconds: 5), (_) { + if (_connection != null) { + sendCommand(RemoteCommand( + type: RemoteCommandType.ping, + deviceId: _myPeerId ?? 'unknown', + deviceName: 'local', + )); + } + }); + } + + void _stopPingTimer() { + _pingTimer?.cancel(); + _pingTimer = null; + } + + void _sendPong(String deviceName, String platform) { + sendCommand(RemoteCommand( + type: RemoteCommandType.pong, + deviceId: _myPeerId ?? 'unknown', + deviceName: deviceName, + data: {'platform': platform}, + )); + } + + void sendDeviceInfo(String deviceName, String platform) { + sendCommand(RemoteCommand( + type: RemoteCommandType.deviceInfo, + deviceId: _myPeerId ?? 'unknown', + deviceName: deviceName, + data: { + 'platform': platform, + 'role': _role?.name, + }, + )); + } + + void sendCommand(RemoteCommand command) { + print('🔵 PeerService sendCommand called! command.type: ${command.type}'); + print('🔵 _connection: $_connection'); + + if (_connection == null) { + print('🔴 PeerService: No connection!'); + appLogger.w('CompanionRemote: No connection to send command'); + return; + } + + try { + print('🔵 Converting command to JSON...'); + final json = command.toJson(); + print('🔵 Sending via connection.send...'); + _connection!.send(json); + print('🔵 Command sent over wire!'); + appLogger.d('CompanionRemote: Sent command: ${command.type}'); + } catch (e) { + print('🔴 PeerService send FAILED: $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(); + _reconnectTimer?.cancel(); + + _connection?.close(); + _connection = null; + + _peer?.dispose(); + _peer = null; + + _sessionId = null; + _pin = null; + _myPeerId = null; + _role = null; + _reconnectAttempts = 0; + + _connectionStateController.add(RemoteSessionStatus.disconnected); + } + + void dispose() { + disconnect(); + _commandReceivedController.close(); + _deviceConnectedController.close(); + _deviceDisconnectedController.close(); + _errorController.close(); + _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..fd2caee2 --- /dev/null +++ b/lib/services/companion_remote/companion_remote_receiver.dart @@ -0,0 +1,137 @@ +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; + + 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: + case RemoteCommandType.audioTracks: + break; // No-op: track cycling not yet implemented + + case RemoteCommandType.fullscreen: + simulateKeyPress(LogicalKeyboardKey.keyF); + + case RemoteCommandType.ping: + case RemoteCommandType.pong: + case RemoteCommandType.ack: + case RemoteCommandType.deviceInfo: + case RemoteCommandType.capabilitiesRequest: + case RemoteCommandType.capabilitiesResponse: + case RemoteCommandType.disconnect: + 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..78fe94c8 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) { @@ -343,12 +344,4 @@ class GamepadService { } } - // 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..f9e0f967 --- /dev/null +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../models/companion_remote/remote_session.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; + + @override + void initState() { + super.initState(); + _createSession(); + } + + Future _createSession() async { + setState(() { + _isCreatingSession = true; + _errorMessage = null; + }); + + try { + final provider = context.read(); + await provider.createSession(); + + setState(() { + _isCreatingSession = false; + }); + } 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) { + return AlertDialog( + title: const Text('Error'), + content: const Text('No session available'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } + + final qrData = '${session.sessionId}:${session.pin}'; + + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Padding( + 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, + '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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 0a25aa35..69e7208d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -14,6 +15,9 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); + flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); g_autoptr(FlPluginRegistrar) os_media_controls_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OsMediaControlsPlugin"); os_media_controls_plugin_register_with_registrar(os_media_controls_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eb699977..a560d685 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_webrtc os_media_controls screen_retriever_linux sqlite3_flutter_libs diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9ec69bfa..dc700d78 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,9 +8,12 @@ import Foundation import connectivity_plus import device_info_plus import file_picker +import flutter_webrtc 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 @@ -24,9 +27,12 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) 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..b2650411 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,13 +5,21 @@ PODS: - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS + - flutter_webrtc (1.2.0): + - FlutterMacOS + - WebRTC-SDK (= 137.7151.04) - 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): @@ -51,17 +59,21 @@ PODS: - FlutterMacOS - wakelock_plus (0.0.1): - FlutterMacOS - - window_manager (0.5.0): + - WebRTC-SDK (137.7151.04) + - window_manager (0.2.0): - FlutterMacOS DEPENDENCIES: - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/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`) @@ -74,6 +86,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - sqlite3 + - WebRTC-SDK EXTERNAL SOURCES: connectivity_plus: @@ -82,14 +95,20 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + flutter_webrtc: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos FlutterMacOS: :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: @@ -111,10 +130,13 @@ SPEC CHECKSUMS: connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737 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 @@ -123,7 +145,8 @@ SPEC CHECKSUMS: universal_gamepad: 8922f1f238f62d6847de887228976d5b572b57da url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b - window_manager: b729e31d38fb04905235df9ea896128991cad99e + WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d diff --git a/pubspec.lock b/pubspec.lock index c9b3df52..442ce73b 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,26 @@ 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" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: "4ed7b9fa9924e5a81eb39271e2c2356739dd1039d60a13b86ba6c5f448625086" + url: "https://pub.dev" + source: hosted + version: "1.7.0" 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 +333,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 +349,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: @@ -345,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" + events_emitter: + dependency: transitive + description: + name: events_emitter + sha256: a075477bdf9c8c0c31bb7c7b7bdd357b4486c34f30163119f96de4e7f54abeff + url: "https://pub.dev" + source: hosted + version: "0.5.2" fake_async: dependency: transitive description: @@ -357,10 +389,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 +421,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 +493,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: "direct overridden" + description: + name: flutter_webrtc + sha256: "0f86b518e9349e71a136a96e0ea11294cad8a8531b2bc9ae99e69df332ac898a" + url: "https://pub.dev" + source: hosted + version: "1.3.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 +525,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 +561,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 +597,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 +617,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 +689,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 +709,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 +733,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 +802,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: @@ -798,6 +838,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + peerdart: + dependency: "direct main" + description: + name: peerdart + sha256: "1d0db041d42194f42e57d8889d029fb8d107610bf1062b39f43f4651de547e3c" + url: "https://pub.dev" + source: hosted + version: "0.5.6" petitparser: dependency: transitive description: @@ -914,10 +962,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 +1026,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 +1103,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 +1207,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 +1267,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 +1303,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 +1343,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 +1423,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: @@ -1395,6 +1451,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: ad0e5786b2acd3be72a3219ef1dde9e1cac071cf4604c685f11b61d63cdd6eb3 + url: "https://pub.dev" + source: hosted + version: "1.4.0" win32: dependency: transitive description: @@ -1477,4 +1541,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..4c939f30 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,11 @@ dependencies: in_app_review: ^2.0.11 dart_discord_presence: ^1.1.0 flutter_svg: ^2.2.3 + mobile_scanner: ^6.0.2 + peerdart: ^0.5.6 + +dependency_overrides: + flutter_webrtc: ^1.3.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bc3ebff2..88e1c798 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FlutterWebRTCPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); OsMediaControlsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index db77f229..1dca6fac 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus + flutter_webrtc os_media_controls screen_retriever_windows sqlite3_flutter_libs