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