Migrate companion remote to WebSocket

This commit is contained in:
Matt Vogel
2026-02-09 18:45:10 -05:00
parent 199d634cb2
commit e30c0b4370
24 changed files with 1035 additions and 1788 deletions
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),
);
}
}
+12 -20
View File
@@ -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(),
);
+12 -13
View File
@@ -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,
};
+46 -49
View File
@@ -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,
};
+19 -20
View File
@@ -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,
};
+27 -10
View File
@@ -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(
+5 -11
View File
@@ -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())),
],
),
);
+1 -4
View File
@@ -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();
}
}
-1
View File
@@ -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);
-1
View File
@@ -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"))
-10
View File
@@ -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
-40
View File
@@ -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:
-4
View File
@@ -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(
-1
View File
@@ -4,7 +4,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_webrtc
os_media_controls
screen_retriever_windows
sqlite3_flutter_libs