Migrate companion remote to WebSocket
This commit is contained in:
+400
-1122
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ class RecentRemoteSession {
|
||||
final String deviceName;
|
||||
final String platform;
|
||||
final DateTime lastConnected;
|
||||
final String? hostAddress; // Format: "ip:port"
|
||||
|
||||
RecentRemoteSession({
|
||||
required this.sessionId,
|
||||
@@ -17,25 +18,32 @@ class RecentRemoteSession {
|
||||
required this.deviceName,
|
||||
required this.platform,
|
||||
required this.lastConnected,
|
||||
this.hostAddress,
|
||||
});
|
||||
|
||||
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) => _$RecentRemoteSessionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$RecentRemoteSessionToJson(this);
|
||||
|
||||
/// Create from QR code data (format: "sessionId:pin:deviceName:platform")
|
||||
/// Create from QR code data (format: "ip|port|sessionId|pin")
|
||||
factory RecentRemoteSession.fromQrData(String qrData) {
|
||||
final parts = qrData.split(':');
|
||||
if (parts.length < 2) {
|
||||
throw FormatException('Invalid QR code format');
|
||||
final parts = qrData.split('|');
|
||||
if (parts.length < 4) {
|
||||
throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin');
|
||||
}
|
||||
|
||||
final ip = parts[0];
|
||||
final port = parts[1];
|
||||
final sessionId = parts[2];
|
||||
final pin = parts[3];
|
||||
|
||||
return RecentRemoteSession(
|
||||
sessionId: parts[0],
|
||||
pin: parts[1],
|
||||
deviceName: parts.length > 2 ? parts[2] : 'Unknown Device',
|
||||
platform: parts.length > 3 ? parts[3] : 'unknown',
|
||||
sessionId: sessionId,
|
||||
pin: pin,
|
||||
deviceName: 'Unknown Device',
|
||||
platform: 'unknown',
|
||||
lastConnected: DateTime.now(),
|
||||
hostAddress: '$ip:$port',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ RecentRemoteSession _$RecentRemoteSessionFromJson(Map<String, dynamic> json) =>
|
||||
deviceName: json['deviceName'] as String,
|
||||
platform: json['platform'] as String,
|
||||
lastConnected: DateTime.parse(json['lastConnected'] as String),
|
||||
hostAddress: json['hostAddress'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecentRemoteSessionToJson(RecentRemoteSession instance) => <String, dynamic>{
|
||||
@@ -20,4 +21,5 @@ Map<String, dynamic> _$RecentRemoteSessionToJson(RecentRemoteSession instance) =
|
||||
'deviceName': instance.deviceName,
|
||||
'platform': instance.platform,
|
||||
'lastConnected': instance.lastConnected.toIso8601String(),
|
||||
'hostAddress': instance.hostAddress,
|
||||
};
|
||||
|
||||
@@ -90,17 +90,19 @@ class RemoteSession {
|
||||
RemoteSessionRole? role,
|
||||
RemoteSessionStatus? status,
|
||||
RemoteDevice? connectedDevice,
|
||||
bool clearConnectedDevice = false,
|
||||
DateTime? createdAt,
|
||||
String? errorMessage,
|
||||
bool clearErrorMessage = false,
|
||||
}) {
|
||||
return RemoteSession(
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
pin: pin ?? this.pin,
|
||||
role: role ?? this.role,
|
||||
status: status ?? this.status,
|
||||
connectedDevice: connectedDevice ?? this.connectedDevice,
|
||||
connectedDevice: clearConnectedDevice ? null : (connectedDevice ?? this.connectedDevice),
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,15 @@ part of 'play_queue_response.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
|
||||
PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedMetadataItemID:
|
||||
json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: const BoolOrIntConverter().fromJson(
|
||||
json['playQueueShuffled'] as Object,
|
||||
),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)?.toInt(),
|
||||
playQueueSelectedMetadataItemID: json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: const BoolOrIntConverter().fromJson(json['playQueueShuffled'] as Object),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
);
|
||||
|
||||
@@ -19,16 +19,15 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
|
||||
hidden: (json['hidden'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
||||
<String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
'agent': instance.agent,
|
||||
'scanner': instance.scanner,
|
||||
'language': instance.language,
|
||||
'uuid': instance.uuid,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'createdAt': instance.createdAt,
|
||||
'hidden': instance.hidden,
|
||||
};
|
||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => <String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
'agent': instance.agent,
|
||||
'scanner': instance.scanner,
|
||||
'language': instance.language,
|
||||
'uuid': instance.uuid,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'createdAt': instance.createdAt,
|
||||
'hidden': instance.hidden,
|
||||
};
|
||||
|
||||
@@ -41,9 +41,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
||||
childCount: (json['childCount'] as num?)?.toInt(),
|
||||
role: (json['Role'] as List<dynamic>?)
|
||||
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
|
||||
@@ -54,49 +52,48 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
||||
clearLogo: json['clearLogo'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'guid': instance.guid,
|
||||
'studio': instance.studio,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'titleSort': instance.titleSort,
|
||||
'contentRating': instance.contentRating,
|
||||
'summary': instance.summary,
|
||||
'rating': instance.rating,
|
||||
'audienceRating': instance.audienceRating,
|
||||
'year': instance.year,
|
||||
'originallyAvailableAt': instance.originallyAvailableAt,
|
||||
'thumb': instance.thumb,
|
||||
'art': instance.art,
|
||||
'duration': instance.duration,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'grandparentTitle': instance.grandparentTitle,
|
||||
'grandparentThumb': instance.grandparentThumb,
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentThumb': instance.parentThumb,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
'grandparentTheme': instance.grandparentTheme,
|
||||
'viewOffset': instance.viewOffset,
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
'childCount': instance.childCount,
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
'playQueueItemID': instance.playQueueItemID,
|
||||
'librarySectionID': instance.librarySectionID,
|
||||
'ratingImage': instance.ratingImage,
|
||||
'audienceRatingImage': instance.audienceRatingImage,
|
||||
'clearLogo': instance.clearLogo,
|
||||
};
|
||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'guid': instance.guid,
|
||||
'studio': instance.studio,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'titleSort': instance.titleSort,
|
||||
'contentRating': instance.contentRating,
|
||||
'summary': instance.summary,
|
||||
'rating': instance.rating,
|
||||
'audienceRating': instance.audienceRating,
|
||||
'year': instance.year,
|
||||
'originallyAvailableAt': instance.originallyAvailableAt,
|
||||
'thumb': instance.thumb,
|
||||
'art': instance.art,
|
||||
'duration': instance.duration,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'grandparentTitle': instance.grandparentTitle,
|
||||
'grandparentThumb': instance.grandparentThumb,
|
||||
'grandparentArt': instance.grandparentArt,
|
||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||
'parentTitle': instance.parentTitle,
|
||||
'parentThumb': instance.parentThumb,
|
||||
'parentRatingKey': instance.parentRatingKey,
|
||||
'parentIndex': instance.parentIndex,
|
||||
'index': instance.index,
|
||||
'grandparentTheme': instance.grandparentTheme,
|
||||
'viewOffset': instance.viewOffset,
|
||||
'viewCount': instance.viewCount,
|
||||
'leafCount': instance.leafCount,
|
||||
'viewedLeafCount': instance.viewedLeafCount,
|
||||
'childCount': instance.childCount,
|
||||
'Role': instance.role,
|
||||
'audioLanguage': instance.audioLanguage,
|
||||
'subtitleLanguage': instance.subtitleLanguage,
|
||||
'playlistItemID': instance.playlistItemID,
|
||||
'playQueueItemID': instance.playQueueItemID,
|
||||
'librarySectionID': instance.librarySectionID,
|
||||
'ratingImage': instance.ratingImage,
|
||||
'audienceRatingImage': instance.audienceRatingImage,
|
||||
'clearLogo': instance.clearLogo,
|
||||
};
|
||||
|
||||
@@ -26,23 +26,22 @@ PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
|
||||
<String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => <String, dynamic>{
|
||||
'ratingKey': instance.ratingKey,
|
||||
'key': instance.key,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'summary': instance.summary,
|
||||
'smart': instance.smart,
|
||||
'playlistType': instance.playlistType,
|
||||
'duration': instance.duration,
|
||||
'leafCount': instance.leafCount,
|
||||
'composite': instance.composite,
|
||||
'addedAt': instance.addedAt,
|
||||
'updatedAt': instance.updatedAt,
|
||||
'lastViewedAt': instance.lastViewedAt,
|
||||
'viewCount': instance.viewCount,
|
||||
'content': instance.content,
|
||||
'guid': instance.guid,
|
||||
'thumb': instance.thumb,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
bool _intentionalDisconnect = false;
|
||||
String? _lastSessionId;
|
||||
String? _lastPin;
|
||||
String? _lastHostAddress;
|
||||
|
||||
int get reconnectAttempts => _reconnectAttempts;
|
||||
|
||||
@@ -130,7 +131,10 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
|
||||
appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)');
|
||||
if (_intentionalDisconnect) {
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.disconnected,
|
||||
clearConnectedDevice: true,
|
||||
);
|
||||
notifyListeners();
|
||||
} else {
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
||||
@@ -182,7 +186,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
_statusSubscription = null;
|
||||
}
|
||||
|
||||
Future<({String sessionId, String pin})> createSession() async {
|
||||
Future<({String sessionId, String pin, String address})> createSession() async {
|
||||
await leaveSession();
|
||||
|
||||
appLogger.d('CompanionRemote: Creating session as host');
|
||||
@@ -201,7 +205,9 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d('CompanionRemote: Session created - ID: ${result.sessionId}, PIN: ${result.pin}');
|
||||
appLogger.d(
|
||||
'CompanionRemote: Session created - ID: ${result.sessionId}, PIN: ${result.pin}, Address: ${result.address}',
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
@@ -218,13 +224,14 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> joinSession(String sessionId, String pin) async {
|
||||
Future<void> joinSession(String sessionId, String pin, String hostAddress) async {
|
||||
await leaveSession();
|
||||
|
||||
_lastSessionId = sessionId;
|
||||
_lastPin = pin;
|
||||
_lastHostAddress = hostAddress;
|
||||
|
||||
appLogger.d('CompanionRemote: Joining session - ID: $sessionId');
|
||||
appLogger.d('CompanionRemote: Joining session - ID: $sessionId, Host: $hostAddress');
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
@@ -238,7 +245,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _peerService!.joinSession(sessionId, pin, _deviceName, _platform);
|
||||
await _peerService!.joinSession(sessionId, pin, _deviceName, _platform, hostAddress);
|
||||
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
notifyListeners();
|
||||
@@ -289,7 +296,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _attemptReconnect() async {
|
||||
if (_lastSessionId == null || _lastPin == null) {
|
||||
if (_lastSessionId == null || _lastPin == null || _lastHostAddress == null) {
|
||||
appLogger.w('CompanionRemote: No stored credentials for reconnect');
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: 'Connection lost');
|
||||
notifyListeners();
|
||||
@@ -305,7 +312,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform);
|
||||
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
|
||||
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
_reconnectAttempts = 0;
|
||||
@@ -330,7 +337,10 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
void cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts = 0;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.disconnected,
|
||||
clearConnectedDevice: true,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -479,6 +489,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
deviceName: deviceToSave.name,
|
||||
platform: deviceToSave.platform,
|
||||
lastConnected: DateTime.now(),
|
||||
hostAddress: _peerService?.hostAddress,
|
||||
);
|
||||
|
||||
if (_discoveryService != null) {
|
||||
@@ -488,7 +499,13 @@ class CompanionRemoteProvider with ChangeNotifier {
|
||||
|
||||
/// Connect to a recent session
|
||||
Future<void> connectToRecentSession(RecentRemoteSession session) async {
|
||||
await joinSession(session.sessionId, session.pin);
|
||||
if (session.hostAddress == null) {
|
||||
throw const RemotePeerError(
|
||||
type: RemotePeerErrorType.invalidSession,
|
||||
message: 'No host address available for this session. Please scan a new QR code.',
|
||||
);
|
||||
}
|
||||
await joinSession(session.sessionId, session.pin, session.hostAddress!);
|
||||
}
|
||||
|
||||
/// Remove a recent session
|
||||
|
||||
@@ -35,14 +35,8 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
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'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Disconnect')),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -68,10 +62,7 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Reconnecting...',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text('Reconnecting...', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Attempt ${provider.reconnectAttempts} of 5',
|
||||
@@ -81,15 +72,9 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => provider.cancelReconnect(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
OutlinedButton(onPressed: () => provider.cancelReconnect(), child: const Text('Cancel')),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: () => provider.retryReconnectNow(),
|
||||
child: const Text('Retry Now'),
|
||||
),
|
||||
FilledButton(onPressed: () => provider.retryReconnectNow(), child: const Text('Retry Now')),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -114,10 +99,7 @@ class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const PairingScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const PairingScreen()));
|
||||
},
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('Connect to Device'),
|
||||
@@ -141,10 +123,7 @@ class _RemoteControlLayout extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
if (PlatformDetector.isDesktop(context)) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: const _RemoteControlContent(),
|
||||
),
|
||||
child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 400), child: const _RemoteControlContent()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,10 +174,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.computer,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
Icon(Icons.computer, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -206,15 +182,15 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
children: [
|
||||
Text(
|
||||
device.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
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,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -222,10 +198,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -269,16 +242,8 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
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.home, label: 'Home', onPressed: () => _sendCommand(RemoteCommandType.home)),
|
||||
_RemoteButton(icon: Icons.arrow_back, label: 'Back', onPressed: () => _sendCommand(RemoteCommandType.back)),
|
||||
_RemoteButton(
|
||||
icon: Icons.menu,
|
||||
label: 'Menu',
|
||||
@@ -287,14 +252,9 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: _DPad(onCommand: _sendCommand),
|
||||
),
|
||||
Center(child: _DPad(onCommand: _sendCommand)),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Tab Navigation',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text('Tab Navigation', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -370,11 +330,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.stop),
|
||||
),
|
||||
_RemoteButton(icon: Icons.stop, label: 'Stop', onPressed: () => _sendCommand(RemoteCommandType.stop)),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.forward_10,
|
||||
@@ -384,10 +340,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Volume',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text('Volume', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -424,11 +377,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_RemoteCard(
|
||||
icon: Icons.search,
|
||||
label: 'Search',
|
||||
onPressed: _showSearchSheet,
|
||||
),
|
||||
_RemoteCard(icon: Icons.search, label: 'Search', onPressed: _showSearchSheet),
|
||||
_RemoteCard(
|
||||
icon: Icons.fullscreen,
|
||||
label: 'Fullscreen',
|
||||
@@ -583,19 +532,12 @@ class _RemoteButton extends StatelessWidget {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -606,11 +548,7 @@ class _RemoteChip extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _RemoteChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
const _RemoteChip({required this.icon, required this.label, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -654,12 +592,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -669,13 +602,8 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> {
|
||||
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),
|
||||
),
|
||||
suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(100)),
|
||||
),
|
||||
onSubmitted: _submit,
|
||||
),
|
||||
@@ -691,11 +619,7 @@ class _RemoteCard extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _RemoteCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
const _RemoteCard({required this.icon, required this.label, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -714,11 +638,7 @@ class _RemoteCard extends StatelessWidget {
|
||||
children: [
|
||||
Icon(icon, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ class PairingScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PairingScreenState extends State<PairingScreen> {
|
||||
final _hostAddressController = TextEditingController();
|
||||
final _sessionIdController = TextEditingController();
|
||||
final _pinController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
@@ -44,6 +45,7 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostAddressController.dispose();
|
||||
_sessionIdController.dispose();
|
||||
_pinController.dispose();
|
||||
_scannerController?.dispose();
|
||||
@@ -105,7 +107,11 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
|
||||
try {
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
await provider.joinSession(_sessionIdController.text.trim().toUpperCase(), _pinController.text.trim());
|
||||
await provider.joinSession(
|
||||
_sessionIdController.text.trim().toUpperCase(),
|
||||
_pinController.text.trim(),
|
||||
_hostAddressController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -133,26 +139,33 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
if (data == _lastScannedCode) return;
|
||||
_lastScannedCode = data;
|
||||
|
||||
final parts = data.split(':');
|
||||
if (parts.length == 2) {
|
||||
// New format: ip|port|sessionId|pin (4 parts separated by pipe)
|
||||
final parts = data.split('|');
|
||||
if (parts.length == 4) {
|
||||
final ip = parts[0];
|
||||
final port = parts[1];
|
||||
final sessionId = parts[2];
|
||||
final pin = parts[3];
|
||||
final hostAddress = '$ip:$port';
|
||||
|
||||
_scannerController?.stop();
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_isConnecting = true;
|
||||
});
|
||||
// Connect directly instead of going through _connect() which requires Form validation
|
||||
_connectWithCredentials(parts[0], parts[1]);
|
||||
_connectWithCredentials(sessionId, pin, hostAddress);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = 'Invalid QR code format';
|
||||
_errorMessage = 'Invalid QR code format - expected 4 parts (ip|port|sessionId|pin)';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectWithCredentials(String sessionId, String pin) async {
|
||||
Future<void> _connectWithCredentials(String sessionId, String pin, String hostAddress) async {
|
||||
try {
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
await provider.joinSession(sessionId.trim().toUpperCase(), pin.trim());
|
||||
await provider.joinSession(sessionId.trim().toUpperCase(), pin.trim(), hostAddress.trim());
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -472,6 +485,33 @@ class _PairingScreenState extends State<PairingScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _hostAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Host Address',
|
||||
hintText: '192.168.1.100:48632',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.computer),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: () => _pasteFromClipboard(_hostAddressController),
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter host address';
|
||||
}
|
||||
// Validate IP:port format
|
||||
final parts = value.split(':');
|
||||
if (parts.length != 2) {
|
||||
return 'Format must be IP:port (e.g., 192.168.1.100:48632)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !_isConnecting,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _sessionIdController,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -1129,7 +1129,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onKeyEvent: isDesktop ? _handleCompanionRemoteKeyEvent : null,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDesktop && _isCompanionRemoteFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||
color: isDesktop && _isCompanionRemoteFocused
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
@@ -1144,10 +1146,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (isDesktop) {
|
||||
RemoteSessionDialog.show(context);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => MobileRemoteScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen()));
|
||||
}
|
||||
},
|
||||
tooltip: 'Companion Remote',
|
||||
@@ -1372,12 +1371,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// 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()),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -816,10 +816,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
: const Text('Control a desktop device'),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen()));
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:peerdart/peerdart.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_command_type.dart';
|
||||
@@ -15,6 +17,8 @@ enum RemotePeerErrorType {
|
||||
serverError,
|
||||
timeout,
|
||||
invalidSession,
|
||||
authFailed,
|
||||
networkError,
|
||||
unknown,
|
||||
}
|
||||
|
||||
@@ -23,22 +27,24 @@ class RemotePeerError {
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
|
||||
const RemotePeerError({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.originalError,
|
||||
});
|
||||
const RemotePeerError({required this.type, required this.message, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => 'RemotePeerError($type): $message';
|
||||
}
|
||||
|
||||
class CompanionRemotePeerService {
|
||||
Peer? _peer;
|
||||
DataConnection? _connection;
|
||||
// Server-side (host) fields
|
||||
HttpServer? _server;
|
||||
WebSocket? _clientSocket;
|
||||
|
||||
// Client-side (remote) fields
|
||||
IOWebSocketChannel? _channel;
|
||||
|
||||
String? _sessionId;
|
||||
String? _pin;
|
||||
String? _myPeerId;
|
||||
String? _hostAddress; // Format: "ip:port"
|
||||
RemoteSessionRole? _role;
|
||||
|
||||
final _commandReceivedController = StreamController<RemoteCommand>.broadcast();
|
||||
@@ -47,9 +53,6 @@ class CompanionRemotePeerService {
|
||||
final _errorController = StreamController<RemotePeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<RemoteSessionStatus>.broadcast();
|
||||
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 3;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _pingTimer;
|
||||
|
||||
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
|
||||
@@ -61,9 +64,10 @@ class CompanionRemotePeerService {
|
||||
String? get sessionId => _sessionId;
|
||||
String? get pin => _pin;
|
||||
String? get myPeerId => _myPeerId;
|
||||
String? get hostAddress => _hostAddress;
|
||||
RemoteSessionRole? get role => _role;
|
||||
bool get isHost => _role == RemoteSessionRole.host;
|
||||
bool get isConnected => _connection != null;
|
||||
bool get isConnected => _clientSocket != null || (_channel != null && _channel?.closeCode == null);
|
||||
|
||||
String _generateSessionId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
@@ -76,263 +80,368 @@ class CompanionRemotePeerService {
|
||||
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();
|
||||
});
|
||||
Future<String> _getLocalIpAddress() async {
|
||||
try {
|
||||
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('CompanionRemote: Peer closed');
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
});
|
||||
// Prefer WiFi interface, then any non-loopback
|
||||
for (final interface in interfaces) {
|
||||
// Skip loopback
|
||||
if (interface.name.toLowerCase().contains('lo')) continue;
|
||||
|
||||
_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);
|
||||
for (final addr in interface.addresses) {
|
||||
if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) {
|
||||
// Prefer names that suggest WiFi/Ethernet
|
||||
if (interface.name.toLowerCase().contains('en') ||
|
||||
interface.name.toLowerCase().contains('wl') ||
|
||||
interface.name.toLowerCase().contains('eth')) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: return any non-loopback IPv4
|
||||
for (final interface in interfaces) {
|
||||
for (final addr in interface.addresses) {
|
||||
if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw const RemotePeerError(type: RemotePeerErrorType.networkError, message: 'No network interface found');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to get local IP', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String sessionId, String pin})> createSession(String deviceName, String platform) async {
|
||||
if (_peer != null) {
|
||||
Future<({String sessionId, String pin, String address})> createSession(String deviceName, String platform) async {
|
||||
if (_server != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_role = RemoteSessionRole.host;
|
||||
_sessionId = _generateSessionId();
|
||||
_pin = _generatePin();
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
final completer = Completer<({String sessionId, String pin})>();
|
||||
_myPeerId = 'host-$_sessionId';
|
||||
|
||||
try {
|
||||
_peer = Peer(id: 'cr-$_sessionId-$_pin');
|
||||
// Try preferred port first, fallback to OS-assigned port
|
||||
const int preferredPort = 48632;
|
||||
|
||||
_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!));
|
||||
try {
|
||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, preferredPort);
|
||||
appLogger.d('CompanionRemote: Server bound to port $preferredPort');
|
||||
} catch (e) {
|
||||
appLogger.w('CompanionRemote: Port $preferredPort occupied, using random port');
|
||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
|
||||
}
|
||||
|
||||
final localIp = await _getLocalIpAddress();
|
||||
final port = _server!.port;
|
||||
_hostAddress = '$localIp:$port';
|
||||
|
||||
appLogger.d('CompanionRemote: Host server started at $_hostAddress');
|
||||
|
||||
// Listen for WebSocket connections
|
||||
_server!.listen((HttpRequest request) async {
|
||||
if (request.uri.path == '/ws') {
|
||||
try {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
_handleNewWebSocketConnection(socket, deviceName, platform);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to upgrade WebSocket', error: e);
|
||||
}
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
request.response.close();
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('connection').listen((conn) {
|
||||
final dataConn = conn as DataConnection;
|
||||
_handleNewConnection(dataConn, deviceName, platform);
|
||||
});
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
_attachCommonPeerListeners(
|
||||
completer: completer,
|
||||
errorType: RemotePeerErrorType.serverError,
|
||||
errorMessage: 'Server error',
|
||||
);
|
||||
return (sessionId: _sessionId!, pin: _pin!, address: _hostAddress!);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to create peer', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
appLogger.e('CompanionRemote: Failed to create server', error: e);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.serverError,
|
||||
message: 'Failed to create server: $e',
|
||||
originalError: e,
|
||||
),
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw RemotePeerError(
|
||||
type: RemotePeerErrorType.timeout,
|
||||
message: 'Timed out creating session',
|
||||
void _handleNewWebSocketConnection(WebSocket socket, String hostDeviceName, String hostPlatform) {
|
||||
appLogger.d('CompanionRemote: New WebSocket connection');
|
||||
|
||||
bool isAuthenticated = false;
|
||||
Timer? authTimeout;
|
||||
|
||||
// Authentication timeout
|
||||
authTimeout = Timer(const Duration(seconds: 10), () {
|
||||
if (!isAuthenticated) {
|
||||
appLogger.w('CompanionRemote: Authentication timeout');
|
||||
socket.close(4001, 'Authentication timeout');
|
||||
}
|
||||
});
|
||||
|
||||
socket.listen(
|
||||
(data) {
|
||||
try {
|
||||
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// First message must be authentication
|
||||
if (json['type'] == 'auth') {
|
||||
final sessionId = json['sessionId'] as String?;
|
||||
final pin = json['pin'] as String?;
|
||||
final deviceName = json['deviceName'] as String?;
|
||||
final platform = json['platform'] as String?;
|
||||
|
||||
if (sessionId == _sessionId && pin == _pin) {
|
||||
isAuthenticated = true;
|
||||
authTimeout?.cancel();
|
||||
|
||||
// Close existing client if present
|
||||
if (_clientSocket != null) {
|
||||
appLogger.d('CompanionRemote: Replacing existing client connection');
|
||||
_clientSocket!.close(4004, 'Replaced by new connection');
|
||||
}
|
||||
|
||||
_clientSocket = socket;
|
||||
|
||||
appLogger.d('CompanionRemote: Client authenticated: $deviceName ($platform)');
|
||||
|
||||
// Send auth success
|
||||
socket.add(jsonEncode({'type': 'authSuccess'}));
|
||||
|
||||
// Notify connection
|
||||
final device = RemoteDevice(
|
||||
id: 'remote-client',
|
||||
name: deviceName ?? 'Unknown Device',
|
||||
platform: platform ?? 'unknown',
|
||||
);
|
||||
_deviceConnectedController.add(device);
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
// Send device info
|
||||
sendDeviceInfo(hostDeviceName, hostPlatform);
|
||||
|
||||
// Note: Client sends keepalive pings, host only responds with pongs
|
||||
} else {
|
||||
appLogger.w('CompanionRemote: Invalid credentials');
|
||||
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'}));
|
||||
socket.close(4003, 'Invalid credentials');
|
||||
}
|
||||
} else {
|
||||
appLogger.w('CompanionRemote: Expected auth, got ${json['type']}');
|
||||
socket.close(4002, 'Authentication required');
|
||||
}
|
||||
} else {
|
||||
// Handle regular commands
|
||||
final command = RemoteCommand.fromJson(json);
|
||||
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
||||
|
||||
// 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) {
|
||||
final ackCommand = RemoteCommand(
|
||||
type: RemoteCommandType.ack,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: hostDeviceName,
|
||||
data: {'originalCommand': command.type.toString()},
|
||||
);
|
||||
socket.add(jsonEncode(ackCommand.toJson()));
|
||||
}
|
||||
|
||||
_commandReceivedController.add(command);
|
||||
|
||||
if (command.type == RemoteCommandType.ping) {
|
||||
_sendPong(hostDeviceName, hostPlatform);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to process message', error: e);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
authTimeout?.cancel();
|
||||
appLogger.d('CompanionRemote: WebSocket connection closed');
|
||||
if (isAuthenticated) {
|
||||
_clientSocket = null;
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
authTimeout?.cancel();
|
||||
appLogger.e('CompanionRemote: WebSocket error', error: error);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.dataChannelError,
|
||||
message: 'WebSocket error: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> joinSession(
|
||||
String sessionId,
|
||||
String pin,
|
||||
String deviceName,
|
||||
String platform,
|
||||
) async {
|
||||
if (_peer != null) {
|
||||
Future<void> joinSession(String sessionId, String pin, String deviceName, String platform, String hostAddress) async {
|
||||
if (_channel != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_role = RemoteSessionRole.remote;
|
||||
_sessionId = sessionId.toUpperCase();
|
||||
_pin = pin;
|
||||
_reconnectAttempts = 0;
|
||||
_hostAddress = hostAddress;
|
||||
_myPeerId = 'remote-${Random().nextInt(99999)}';
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
try {
|
||||
_peer = Peer();
|
||||
final url = 'ws://$hostAddress/ws';
|
||||
appLogger.d('CompanionRemote: Connecting to $url');
|
||||
|
||||
_peer!.on('open').listen((id) {
|
||||
_myPeerId = id as String;
|
||||
appLogger.d('CompanionRemote: Remote peer opened with ID: $_myPeerId');
|
||||
_connectionStateController.add(RemoteSessionStatus.connecting);
|
||||
|
||||
final hostPeerId = 'cr-$_sessionId-$_pin';
|
||||
appLogger.d('CompanionRemote: Connecting to host: $hostPeerId');
|
||||
_channel = IOWebSocketChannel.connect(Uri.parse(url));
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.connecting);
|
||||
|
||||
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
|
||||
_handleNewConnection(conn, deviceName, platform, isOutgoing: true, completer: completer);
|
||||
// Send authentication message
|
||||
final authMessage = jsonEncode({
|
||||
'type': 'auth',
|
||||
'sessionId': _sessionId,
|
||||
'pin': _pin,
|
||||
'deviceName': deviceName,
|
||||
'platform': platform,
|
||||
});
|
||||
_channel!.sink.add(authMessage);
|
||||
|
||||
_attachCommonPeerListeners(
|
||||
completer: completer,
|
||||
errorType: RemotePeerErrorType.connectionFailed,
|
||||
errorMessage: 'Failed to connect to session',
|
||||
// Listen for messages
|
||||
_channel!.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
final messageType = json['type'] as String?;
|
||||
|
||||
if (messageType == 'authSuccess') {
|
||||
appLogger.d('CompanionRemote: Authentication successful');
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
|
||||
final device = RemoteDevice(id: 'host', name: 'Desktop', platform: 'desktop');
|
||||
_deviceConnectedController.add(device);
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
// Send device info
|
||||
sendDeviceInfo(deviceName, platform);
|
||||
|
||||
// Start ping timer
|
||||
_startPingTimer();
|
||||
} else if (messageType == 'authFailed') {
|
||||
final message = json['message'] as String? ?? 'Authentication failed';
|
||||
appLogger.w('CompanionRemote: $message');
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(RemotePeerError(type: RemotePeerErrorType.authFailed, message: message));
|
||||
}
|
||||
|
||||
_errorController.add(RemotePeerError(type: RemotePeerErrorType.authFailed, message: message));
|
||||
_connectionStateController.add(RemoteSessionStatus.error);
|
||||
} else {
|
||||
// Regular command
|
||||
final command = RemoteCommand.fromJson(json);
|
||||
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
||||
|
||||
// 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) {
|
||||
final ackCommand = RemoteCommand(
|
||||
type: RemoteCommandType.ack,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {'originalCommand': command.type.toString()},
|
||||
);
|
||||
_channel!.sink.add(jsonEncode(ackCommand.toJson()));
|
||||
}
|
||||
|
||||
_commandReceivedController.add(command);
|
||||
|
||||
if (command.type == RemoteCommandType.ping) {
|
||||
_sendPong(deviceName, platform);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to parse message', error: e);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
appLogger.d('CompanionRemote: Connection closed');
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
// Reconnection is handled by CompanionRemoteProvider
|
||||
},
|
||||
onError: (error) {
|
||||
appLogger.e('CompanionRemote: Connection error', error: error);
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.connectionFailed,
|
||||
message: 'Connection error: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
_connectionStateController.add(RemoteSessionStatus.error);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to create peer for joining', error: e);
|
||||
appLogger.e('CompanionRemote: Failed to connect', error: e);
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
|
||||
_errorController.add(
|
||||
RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'Failed to connect: $e', originalError: e),
|
||||
);
|
||||
}
|
||||
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
throw RemotePeerError(
|
||||
type: RemotePeerErrorType.timeout,
|
||||
message: 'Timed out joining session',
|
||||
);
|
||||
onTimeout: () async {
|
||||
// Clean up channel on timeout
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNewConnection(
|
||||
DataConnection conn,
|
||||
String deviceName,
|
||||
String platform, {
|
||||
bool isOutgoing = false,
|
||||
Completer<void>? 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) {
|
||||
try {
|
||||
final json = data as Map<String, dynamic>;
|
||||
final command = RemoteCommand.fromJson(json);
|
||||
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) {
|
||||
final ackCommand = RemoteCommand(
|
||||
type: RemoteCommandType.ack,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {'originalCommand': command.type.toString()},
|
||||
);
|
||||
_connection?.send(ackCommand.toJson());
|
||||
}
|
||||
|
||||
_commandReceivedController.add(command);
|
||||
|
||||
if (command.type == RemoteCommandType.ping) {
|
||||
_sendPong(deviceName, platform);
|
||||
} else if (command.type == RemoteCommandType.ack) {
|
||||
appLogger.d('CompanionRemote: Received ACK for: ${json['data']?['originalCommand']}');
|
||||
}
|
||||
} catch (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',
|
||||
));
|
||||
if (isConnected) {
|
||||
sendCommand(RemoteCommand(type: RemoteCommandType.ping, deviceId: _myPeerId ?? 'unknown', deviceName: 'local'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -343,36 +452,40 @@ class CompanionRemotePeerService {
|
||||
}
|
||||
|
||||
void _sendPong(String deviceName, String platform) {
|
||||
sendCommand(RemoteCommand(
|
||||
type: RemoteCommandType.pong,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {'platform': 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,
|
||||
},
|
||||
));
|
||||
sendCommand(
|
||||
RemoteCommand(
|
||||
type: RemoteCommandType.deviceInfo,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {'platform': platform, 'role': _role?.name},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void sendCommand(RemoteCommand command) {
|
||||
if (_connection == null) {
|
||||
appLogger.w('CompanionRemote: No connection to send command');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final json = command.toJson();
|
||||
_connection!.send(json);
|
||||
appLogger.d('CompanionRemote: Sent command: ${command.type}');
|
||||
final json = jsonEncode(command.toJson());
|
||||
|
||||
if (_role == RemoteSessionRole.host && _clientSocket != null) {
|
||||
_clientSocket!.add(json);
|
||||
appLogger.d('CompanionRemote: Sent command (host): ${command.type}');
|
||||
} else if (_role == RemoteSessionRole.remote && _channel != null) {
|
||||
_channel!.sink.add(json);
|
||||
appLogger.d('CompanionRemote: Sent command (remote): ${command.type}');
|
||||
} else {
|
||||
appLogger.w('CompanionRemote: No connection to send command');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to send command', error: e);
|
||||
_errorController.add(
|
||||
@@ -389,29 +502,37 @@ class CompanionRemotePeerService {
|
||||
appLogger.d('CompanionRemote: Disconnecting');
|
||||
|
||||
_stopPingTimer();
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
_connection?.close();
|
||||
_connection = null;
|
||||
if (_clientSocket != null) {
|
||||
await _clientSocket!.close();
|
||||
_clientSocket = null;
|
||||
}
|
||||
|
||||
_peer?.dispose();
|
||||
_peer = null;
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
if (_server != null) {
|
||||
await _server!.close();
|
||||
_server = null;
|
||||
}
|
||||
|
||||
_sessionId = null;
|
||||
_pin = null;
|
||||
_myPeerId = null;
|
||||
_hostAddress = null;
|
||||
_role = null;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_commandReceivedController.close();
|
||||
_deviceConnectedController.close();
|
||||
_deviceDisconnectedController.close();
|
||||
_errorController.close();
|
||||
_connectionStateController.close();
|
||||
Future<void> dispose() async {
|
||||
await disconnect();
|
||||
await _commandReceivedController.close();
|
||||
await _deviceConnectedController.close();
|
||||
await _deviceDisconnectedController.close();
|
||||
await _errorController.close();
|
||||
await _connectionStateController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,5 +343,4 @@ class GamepadService {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class RemoteSessionDialog extends StatefulWidget {
|
||||
class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
bool _isCreatingSession = false;
|
||||
String? _errorMessage;
|
||||
String? _hostAddress; // Format: "ip:port"
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,14 +36,16 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
setState(() {
|
||||
_isCreatingSession = true;
|
||||
_errorMessage = null;
|
||||
_hostAddress = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
await provider.createSession();
|
||||
final result = await provider.createSession();
|
||||
|
||||
setState(() {
|
||||
_isCreatingSession = false;
|
||||
_hostAddress = result.address;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create companion remote session', error: e);
|
||||
@@ -55,12 +58,9 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$label copied to clipboard'), duration: const Duration(seconds: 2)));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -76,10 +76,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Creating remote session...',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text('Creating remote session...', style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -95,40 +92,32 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
children: [
|
||||
const Text('Failed to create remote session:'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||||
TextButton(onPressed: _createSession, child: const Text('Retry')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final session = provider.session;
|
||||
if (session == null) {
|
||||
if (session == null || _hostAddress == null) {
|
||||
return AlertDialog(
|
||||
title: const Text('Error'),
|
||||
content: const Text('No session available'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close'))],
|
||||
);
|
||||
}
|
||||
|
||||
final qrData = '${session.sessionId}:${session.pin}';
|
||||
// Parse IP and port from hostAddress
|
||||
final addressParts = _hostAddress!.split(':');
|
||||
final ip = addressParts[0];
|
||||
final port = addressParts[1];
|
||||
|
||||
// New QR format: ip|port|sessionId|pin (using pipe separator)
|
||||
final qrData = '$ip|$port|${session.sessionId}|${session.pin}';
|
||||
|
||||
return Dialog(
|
||||
child: ConstrainedBox(
|
||||
@@ -147,45 +136,32 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Companion Remote',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
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,
|
||||
),
|
||||
color: session.connectedDevice != null
|
||||
? Colors.green
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
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),
|
||||
),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: QrImageView(
|
||||
data: qrData,
|
||||
version: QrVersions.auto,
|
||||
@@ -203,6 +179,13 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildCodeCard(
|
||||
context,
|
||||
'Host Address',
|
||||
_hostAddress!,
|
||||
onCopy: () => _copyToClipboard(_hostAddress!, 'Host Address'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCodeCard(
|
||||
context,
|
||||
'Session ID',
|
||||
@@ -210,37 +193,19 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
onCopy: () => _copyToClipboard(session.sessionId, 'Session ID'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCodeCard(
|
||||
context,
|
||||
'PIN',
|
||||
session.pin,
|
||||
onCopy: () => _copyToClipboard(session.pin, 'PIN'),
|
||||
),
|
||||
_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 Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connected',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
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,
|
||||
),
|
||||
Text(session.connectedDevice!.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(session.connectedDevice!.platform, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -276,10 +241,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Minimize'),
|
||||
),
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Minimize')),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -291,12 +253,7 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeCard(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String code, {
|
||||
VoidCallback? onCopy,
|
||||
}) {
|
||||
Widget _buildCodeCard(BuildContext context, String label, String code, {VoidCallback? onCopy}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
@@ -306,26 +263,16 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
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,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontFamily: 'monospace', letterSpacing: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: onCopy,
|
||||
tooltip: 'Copy to clipboard',
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.copy), onPressed: onCopy, tooltip: 'Copy to clipboard'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <os_media_controls/os_media_controls_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
@@ -15,9 +14,6 @@
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_webrtc
|
||||
os_media_controls
|
||||
screen_retriever_linux
|
||||
sqlite3_flutter_libs
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -27,7 +26,6 @@ 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"))
|
||||
|
||||
@@ -5,9 +5,6 @@ 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
|
||||
@@ -59,7 +56,6 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (137.7151.04)
|
||||
- window_manager (0.5.0):
|
||||
- FlutterMacOS
|
||||
|
||||
@@ -67,7 +63,6 @@ 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`)
|
||||
@@ -86,7 +81,6 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- sqlite3
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
@@ -95,8 +89,6 @@ 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:
|
||||
@@ -130,7 +122,6 @@ SPEC CHECKSUMS:
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||
flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
|
||||
mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3
|
||||
@@ -145,7 +136,6 @@ SPEC CHECKSUMS:
|
||||
universal_gamepad: 8922f1f238f62d6847de887228976d5b572b57da
|
||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
||||
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
||||
|
||||
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d
|
||||
|
||||
@@ -297,14 +297,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
@@ -369,14 +361,6 @@ 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:
|
||||
@@ -493,14 +477,6 @@ 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:
|
||||
@@ -838,14 +814,6 @@ 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:
|
||||
@@ -1451,14 +1419,6 @@ 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:
|
||||
|
||||
@@ -49,10 +49,6 @@ dependencies:
|
||||
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:
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <os_media_controls/os_media_controls_plugin_c_api.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
@@ -18,8 +17,6 @@
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||
OsMediaControlsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
flutter_webrtc
|
||||
os_media_controls
|
||||
screen_retriever_windows
|
||||
sqlite3_flutter_libs
|
||||
|
||||
Reference in New Issue
Block a user