Add Companion Remote for mobile-to-desktop control
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user