Add Companion Remote for mobile-to-desktop control

This commit is contained in:
Matt Vogel
2026-02-09 14:19:54 -05:00
parent 0dc1797eb9
commit 372767b142
31 changed files with 4140 additions and 130 deletions
@@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:convert';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
/// Recent Companion Remote session for quick reconnection
class RecentRemoteSession {
final String sessionId;
final String pin;
final String deviceName;
final String platform;
final DateTime lastConnected;
RecentRemoteSession({
required this.sessionId,
required this.pin,
required this.deviceName,
required this.platform,
required this.lastConnected,
});
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) {
return RecentRemoteSession(
sessionId: json['sessionId'] as String,
pin: json['pin'] as String,
deviceName: json['deviceName'] as String,
platform: json['platform'] as String,
lastConnected: DateTime.parse(json['lastConnected'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'sessionId': sessionId,
'pin': pin,
'deviceName': deviceName,
'platform': platform,
'lastConnected': lastConnected.toIso8601String(),
};
}
/// Create from QR code data (format: "sessionId:pin:deviceName:platform")
factory RecentRemoteSession.fromQrData(String qrData) {
final parts = qrData.split(':');
if (parts.length < 2) {
throw FormatException('Invalid QR code format');
}
return RecentRemoteSession(
sessionId: parts[0],
pin: parts[1],
deviceName: parts.length > 2 ? parts[2] : 'Unknown Device',
platform: parts.length > 3 ? parts[3] : 'unknown',
lastConnected: DateTime.now(),
);
}
@override
String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}';
}
/// Service for managing recent Companion Remote sessions
class CompanionRemoteDiscoveryService {
static const String _storageKey = 'companion_remote_recent_sessions';
static const int _maxRecentSessions = 5;
final _recentSessions = <RecentRemoteSession>[];
final _recentSessionsController = StreamController<List<RecentRemoteSession>>.broadcast();
/// Stream of recent sessions
Stream<List<RecentRemoteSession>> get recentSessions => _recentSessionsController.stream;
/// Get current list of recent sessions
List<RecentRemoteSession> get currentSessions => List.unmodifiable(_recentSessions);
CompanionRemoteDiscoveryService() {
_loadRecentSessions();
}
/// Load recent sessions from storage
Future<void> _loadRecentSessions() async {
try {
final storage = await StorageService.getInstance();
final json = storage.prefs.getString(_storageKey);
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_recentSessions.clear();
_recentSessions.addAll(
list.map((e) => RecentRemoteSession.fromJson(e as Map<String, dynamic>)),
);
// Sort by last connected (most recent first)
_recentSessions.sort((a, b) => b.lastConnected.compareTo(a.lastConnected));
_recentSessionsController.add(currentSessions);
appLogger.d('Loaded ${_recentSessions.length} recent remote sessions');
}
} catch (e) {
appLogger.e('Failed to load recent sessions', error: e);
}
}
/// Save recent sessions to storage
Future<void> _saveRecentSessions() async {
try {
final storage = await StorageService.getInstance();
final json = jsonEncode(_recentSessions.map((e) => e.toJson()).toList());
await storage.prefs.setString(_storageKey, json);
appLogger.d('Saved ${_recentSessions.length} recent remote sessions');
} catch (e) {
appLogger.e('Failed to save recent sessions', error: e);
}
}
/// Add a session to recent list
Future<void> addRecentSession(RecentRemoteSession session) async {
// Remove existing entry for this session ID
_recentSessions.removeWhere((s) => s.sessionId == session.sessionId);
// Add new entry at the beginning
_recentSessions.insert(0, session);
// Limit to max sessions
if (_recentSessions.length > _maxRecentSessions) {
_recentSessions.removeRange(_maxRecentSessions, _recentSessions.length);
}
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Remove a session from recent list
Future<void> removeRecentSession(String sessionId) async {
_recentSessions.removeWhere((s) => s.sessionId == sessionId);
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Clear all recent sessions
Future<void> clearRecentSessions() async {
_recentSessions.clear();
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Dispose resources
Future<void> dispose() async {
await _recentSessionsController.close();
}
}
@@ -0,0 +1,435 @@
import 'dart:async';
import 'dart:math';
import 'package:peerdart/peerdart.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../utils/app_logger.dart';
enum RemotePeerErrorType {
connectionFailed,
peerDisconnected,
dataChannelError,
serverError,
timeout,
invalidSession,
unknown,
}
class RemotePeerError {
final RemotePeerErrorType type;
final String message;
final dynamic originalError;
const RemotePeerError({
required this.type,
required this.message,
this.originalError,
});
@override
String toString() => 'RemotePeerError($type): $message';
}
class CompanionRemotePeerService {
Peer? _peer;
DataConnection? _connection;
String? _sessionId;
String? _pin;
String? _myPeerId;
RemoteSessionRole? _role;
final _commandReceivedController = StreamController<RemoteCommand>.broadcast();
final _deviceConnectedController = StreamController<RemoteDevice>.broadcast();
final _deviceDisconnectedController = StreamController<void>.broadcast();
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;
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
Stream<RemotePeerError> get onError => _errorController.stream;
Stream<RemoteSessionStatus> get onConnectionStateChanged => _connectionStateController.stream;
String? get sessionId => _sessionId;
String? get pin => _pin;
String? get myPeerId => _myPeerId;
RemoteSessionRole? get role => _role;
bool get isHost => _role == RemoteSessionRole.host;
bool get isConnected => _connection != null;
String _generateSessionId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
final random = Random.secure();
return List.generate(8, (index) => chars[random.nextInt(chars.length)]).join();
}
String _generatePin() {
final random = Random.secure();
return List.generate(6, (index) => random.nextInt(10).toString()).join();
}
void _attachCommonPeerListeners({
required Completer completer,
required RemotePeerErrorType errorType,
required String errorMessage,
}) {
_peer!.on('disconnected').listen((_) {
appLogger.w('CompanionRemote: Peer disconnected from server');
_handleDisconnectedFromServer();
});
_peer!.on('close').listen((_) {
appLogger.d('CompanionRemote: Peer closed');
_connectionStateController.add(RemoteSessionStatus.disconnected);
});
_peer!.on('error').listen((error) {
appLogger.e('CompanionRemote: Peer error', error: error);
_errorController.add(
RemotePeerError(
type: errorType,
message: '$errorMessage: $error',
originalError: error,
),
);
if (!completer.isCompleted) {
completer.completeError(error);
}
});
}
Future<({String sessionId, String pin})> createSession(String deviceName, String platform) async {
if (_peer != null) {
await disconnect();
}
_role = RemoteSessionRole.host;
_sessionId = _generateSessionId();
_pin = _generatePin();
_reconnectAttempts = 0;
final completer = Completer<({String sessionId, String pin})>();
try {
_peer = Peer(id: 'cr-$_sessionId-$_pin');
_peer!.on('open').listen((id) {
_myPeerId = id as String;
appLogger.d('CompanionRemote: Host peer opened with ID: $_myPeerId');
_connectionStateController.add(RemoteSessionStatus.connected);
if (!completer.isCompleted) {
completer.complete((sessionId: _sessionId!, pin: _pin!));
}
});
_peer!.on('connection').listen((conn) {
final dataConn = conn as DataConnection;
_handleNewConnection(dataConn, deviceName, platform);
});
_attachCommonPeerListeners(
completer: completer,
errorType: RemotePeerErrorType.serverError,
errorMessage: 'Server error',
);
} catch (e) {
appLogger.e('CompanionRemote: Failed to create peer', error: e);
if (!completer.isCompleted) {
completer.completeError(e);
}
}
return completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw RemotePeerError(
type: RemotePeerErrorType.timeout,
message: 'Timed out creating session',
);
},
);
}
Future<void> joinSession(
String sessionId,
String pin,
String deviceName,
String platform,
) async {
if (_peer != null) {
await disconnect();
}
_role = RemoteSessionRole.remote;
_sessionId = sessionId.toUpperCase();
_pin = pin;
_reconnectAttempts = 0;
final completer = Completer<void>();
try {
_peer = Peer();
_peer!.on('open').listen((id) {
_myPeerId = id as String;
appLogger.d('CompanionRemote: Remote peer opened with ID: $_myPeerId');
final hostPeerId = 'cr-$_sessionId-$_pin';
appLogger.d('CompanionRemote: Connecting to host: $hostPeerId');
_connectionStateController.add(RemoteSessionStatus.connecting);
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
_handleNewConnection(conn, deviceName, platform, isOutgoing: true, completer: completer);
});
_attachCommonPeerListeners(
completer: completer,
errorType: RemotePeerErrorType.connectionFailed,
errorMessage: 'Failed to connect to session',
);
} catch (e) {
appLogger.e('CompanionRemote: Failed to create peer for joining', error: e);
if (!completer.isCompleted) {
completer.completeError(e);
}
}
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
throw RemotePeerError(
type: RemotePeerErrorType.timeout,
message: 'Timed out joining session',
);
},
);
}
void _handleNewConnection(
DataConnection conn,
String deviceName,
String platform, {
bool isOutgoing = false,
Completer<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) {
print('🟡 PeerService: DATA RECEIVED! data type: ${data.runtimeType}');
try {
print('🟡 Parsing as JSON...');
final json = data as Map<String, dynamic>;
print('🟡 Creating RemoteCommand from JSON...');
final command = RemoteCommand.fromJson(json);
print('🟡 Command received: ${command.type} from $peerId');
appLogger.d('CompanionRemote: Received command: ${command.type} from $peerId');
// Send acknowledgment for non-ping/pong/ack commands
if (command.type != RemoteCommandType.ping &&
command.type != RemoteCommandType.pong &&
command.type != RemoteCommandType.ack &&
command.type != RemoteCommandType.deviceInfo) {
print('🟡 Sending ACK for ${command.type}...');
final ackCommand = RemoteCommand(
type: RemoteCommandType.ack,
deviceId: _myPeerId ?? 'unknown',
deviceName: deviceName,
data: {'originalCommand': command.type.toString()},
);
_connection?.send(ackCommand.toJson());
print('🟡 ACK sent!');
}
print('🟡 Adding to _commandReceivedController...');
_commandReceivedController.add(command);
print('🟡 Command added to stream!');
if (command.type == RemoteCommandType.ping) {
print('🟡 Responding to ping with pong...');
_sendPong(deviceName, platform);
} else if (command.type == RemoteCommandType.ack) {
print('✅ RECEIVED ACK from desktop for: ${json['data']?['originalCommand']}');
}
} catch (e) {
print('🔴 PeerService: Failed to parse command: $e');
appLogger.e('CompanionRemote: Failed to parse command', error: e);
}
});
conn.on('close').listen((_) {
appLogger.d('CompanionRemote: Connection closed with: $peerId');
_connection = null;
_deviceDisconnectedController.add(null);
_connectionStateController.add(RemoteSessionStatus.disconnected);
_stopPingTimer();
});
conn.on('error').listen((error) {
appLogger.e('CompanionRemote: Connection error with $peerId', error: error);
_errorController.add(
RemotePeerError(
type: RemotePeerErrorType.dataChannelError,
message: 'Connection error with peer: $error',
originalError: error,
),
);
_connectionStateController.add(RemoteSessionStatus.error);
});
}
void _handleDisconnectedFromServer() {
if (_reconnectAttempts < _maxReconnectAttempts) {
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2);
appLogger.d(
'CompanionRemote: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s',
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
_peer?.reconnect();
});
} else {
appLogger.e('CompanionRemote: Max reconnect attempts reached');
_errorController.add(
const RemotePeerError(
type: RemotePeerErrorType.connectionFailed,
message: 'Lost connection to server after multiple reconnect attempts',
),
);
_connectionStateController.add(RemoteSessionStatus.error);
}
}
void _startPingTimer() {
_stopPingTimer();
_pingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (_connection != null) {
sendCommand(RemoteCommand(
type: RemoteCommandType.ping,
deviceId: _myPeerId ?? 'unknown',
deviceName: 'local',
));
}
});
}
void _stopPingTimer() {
_pingTimer?.cancel();
_pingTimer = null;
}
void _sendPong(String deviceName, String platform) {
sendCommand(RemoteCommand(
type: RemoteCommandType.pong,
deviceId: _myPeerId ?? 'unknown',
deviceName: deviceName,
data: {'platform': platform},
));
}
void sendDeviceInfo(String deviceName, String platform) {
sendCommand(RemoteCommand(
type: RemoteCommandType.deviceInfo,
deviceId: _myPeerId ?? 'unknown',
deviceName: deviceName,
data: {
'platform': platform,
'role': _role?.name,
},
));
}
void sendCommand(RemoteCommand command) {
print('🔵 PeerService sendCommand called! command.type: ${command.type}');
print('🔵 _connection: $_connection');
if (_connection == null) {
print('🔴 PeerService: No connection!');
appLogger.w('CompanionRemote: No connection to send command');
return;
}
try {
print('🔵 Converting command to JSON...');
final json = command.toJson();
print('🔵 Sending via connection.send...');
_connection!.send(json);
print('🔵 Command sent over wire!');
appLogger.d('CompanionRemote: Sent command: ${command.type}');
} catch (e) {
print('🔴 PeerService send FAILED: $e');
appLogger.e('CompanionRemote: Failed to send command', error: e);
_errorController.add(
RemotePeerError(
type: RemotePeerErrorType.dataChannelError,
message: 'Failed to send command: $e',
originalError: e,
),
);
}
}
Future<void> disconnect() async {
appLogger.d('CompanionRemote: Disconnecting');
_stopPingTimer();
_reconnectTimer?.cancel();
_connection?.close();
_connection = null;
_peer?.dispose();
_peer = null;
_sessionId = null;
_pin = null;
_myPeerId = null;
_role = null;
_reconnectAttempts = 0;
_connectionStateController.add(RemoteSessionStatus.disconnected);
}
void dispose() {
disconnect();
_commandReceivedController.close();
_deviceConnectedController.close();
_deviceDisconnectedController.close();
_errorController.close();
_connectionStateController.close();
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../utils/app_logger.dart';
import '../../utils/key_event_simulator.dart';
class CompanionRemoteReceiver {
CompanionRemoteReceiver._();
static CompanionRemoteReceiver? _instance;
static CompanionRemoteReceiver get instance {
_instance ??= CompanionRemoteReceiver._();
return _instance!;
}
/// Called on any remote input so InputModeTracker can switch to keyboard mode.
/// Same pattern as [GamepadService.onGamepadInput].
static VoidCallback? onRemoteInput;
VoidCallback? onTabNext;
VoidCallback? onTabPrevious;
VoidCallback? onTabDiscover;
VoidCallback? onTabLibraries;
VoidCallback? onTabSearch;
VoidCallback? onTabDownloads;
VoidCallback? onTabSettings;
VoidCallback? onHome;
void Function(String? query)? onSearchAction;
VoidCallback? onNextTrack;
VoidCallback? onPreviousTrack;
VoidCallback? onStop;
VoidCallback? onSeekForward;
VoidCallback? onSeekBackward;
VoidCallback? onVolumeUp;
VoidCallback? onVolumeDown;
VoidCallback? onVolumeMute;
void handleCommand(RemoteCommand command, BuildContext? context) {
appLogger.d('CompanionRemoteReceiver: Handling command: ${command.type}');
// Switch to keyboard mode so focus visuals render
onRemoteInput?.call();
_setTraditionalFocusHighlight();
scheduleFrameIfIdle();
switch (command.type) {
case RemoteCommandType.dpadUp:
simulateKeyPress(LogicalKeyboardKey.arrowUp);
case RemoteCommandType.dpadDown:
simulateKeyPress(LogicalKeyboardKey.arrowDown);
case RemoteCommandType.dpadLeft:
simulateKeyPress(LogicalKeyboardKey.arrowLeft);
case RemoteCommandType.dpadRight:
simulateKeyPress(LogicalKeyboardKey.arrowRight);
case RemoteCommandType.select:
simulateKeyPress(LogicalKeyboardKey.enter);
case RemoteCommandType.back:
simulateKeyPress(LogicalKeyboardKey.escape);
case RemoteCommandType.contextMenu:
simulateKeyPress(LogicalKeyboardKey.contextMenu);
case RemoteCommandType.play:
simulateKeyPress(LogicalKeyboardKey.space);
case RemoteCommandType.pause:
simulateKeyPress(LogicalKeyboardKey.space);
case RemoteCommandType.playPause:
simulateKeyPress(LogicalKeyboardKey.space);
case RemoteCommandType.seekForward:
onSeekForward?.call();
case RemoteCommandType.seekBackward:
onSeekBackward?.call();
case RemoteCommandType.volumeUp:
onVolumeUp?.call();
case RemoteCommandType.volumeDown:
onVolumeDown?.call();
case RemoteCommandType.volumeMute:
onVolumeMute?.call();
case RemoteCommandType.tabNext:
onTabNext?.call();
case RemoteCommandType.tabPrevious:
onTabPrevious?.call();
case RemoteCommandType.tabDiscover:
onTabDiscover?.call();
case RemoteCommandType.tabLibraries:
onTabLibraries?.call();
case RemoteCommandType.tabSearch:
onTabSearch?.call();
case RemoteCommandType.tabDownloads:
onTabDownloads?.call();
case RemoteCommandType.tabSettings:
onTabSettings?.call();
case RemoteCommandType.home:
onHome?.call();
case RemoteCommandType.search:
final query = command.data?['query'] as String?;
onSearchAction?.call(query);
case RemoteCommandType.stop:
onStop?.call();
case RemoteCommandType.nextTrack:
onNextTrack?.call();
case RemoteCommandType.previousTrack:
onPreviousTrack?.call();
case RemoteCommandType.subtitles:
case RemoteCommandType.audioTracks:
break; // No-op: track cycling not yet implemented
case RemoteCommandType.fullscreen:
simulateKeyPress(LogicalKeyboardKey.keyF);
case RemoteCommandType.ping:
case RemoteCommandType.pong:
case RemoteCommandType.ack:
case RemoteCommandType.deviceInfo:
case RemoteCommandType.capabilitiesRequest:
case RemoteCommandType.capabilitiesResponse:
case RemoteCommandType.disconnect:
break;
default:
appLogger.w('CompanionRemoteReceiver: Unhandled command type: ${command.type}');
}
}
void _setTraditionalFocusHighlight() {
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
}
}
}