diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index ebe5b1bc..8c6cb1a0 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -5,6 +5,13 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../../app/app_controller.dart'; import '../../runtime/gateway_runtime_helpers.dart'; +const String desktopReliableInputChannelLabel = 'input'; +const String desktopMoveInputChannelLabel = 'input-move'; +const int desktopReliableInputChannelId = 0; +const int desktopMoveInputChannelId = 1; +const int desktopMoveChannelMaxPacketLifeTimeMs = 100; +const int desktopMoveBufferedAmountLimit = 16 * 1024; + String desktopConnectionStateName(RTCPeerConnectionState state) { final value = state.toString().split('.').last; return value.replaceFirst('RTCPeerConnectionState', '').toLowerCase(); @@ -35,11 +42,30 @@ Map desktopOfferParams({ bool desktopShouldDropInputEvent( Map event, { required int bufferedAmount, - int bufferedAmountLimit = 64 * 1024, + int bufferedAmountLimit = desktopMoveBufferedAmountLimit, }) { return event['type'] == 'mouse_move' && bufferedAmount > bufferedAmountLimit; } +String desktopInputChannelLabelForEvent(Map event) { + return event['type'] == 'mouse_move' + ? desktopMoveInputChannelLabel + : desktopReliableInputChannelLabel; +} + +RTCDataChannelInit desktopReliableInputChannelConfig() { + return RTCDataChannelInit() + ..ordered = true + ..id = desktopReliableInputChannelId; +} + +RTCDataChannelInit desktopMoveInputChannelConfig() { + return RTCDataChannelInit() + ..ordered = false + ..id = desktopMoveInputChannelId + ..maxRetransmitTime = desktopMoveChannelMaxPacketLifeTimeMs; +} + bool desktopHasRenderedVideoFrame({ required bool hasStream, required int rendererVideoWidth, @@ -179,7 +205,8 @@ class DesktopClient { final String sessionId; RTCPeerConnection? _peerConnection; - RTCDataChannel? _dataChannel; + RTCDataChannel? _inputChannel; + RTCDataChannel? _moveInputChannel; final StreamController _streamController = StreamController.broadcast(); @@ -244,11 +271,14 @@ class DesktopClient { _stateController.add(desktopConnectionStateName(state)); }; - // Create data channel for inputs BEFORE creating offer - final dcConfig = RTCDataChannelInit()..ordered = true; - _dataChannel = await _peerConnection!.createDataChannel( - 'input', - dcConfig, + // Create input data channels BEFORE creating the offer. + _inputChannel = await _peerConnection!.createDataChannel( + desktopReliableInputChannelLabel, + desktopReliableInputChannelConfig(), + ); + _moveInputChannel = await _peerConnection!.createDataChannel( + desktopMoveInputChannelLabel, + desktopMoveInputChannelConfig(), ); // Handle ICE Candidates generated locally @@ -341,7 +371,10 @@ class DesktopClient { } void sendInput(Map event) { - final channel = _dataChannel; + final channel = + desktopInputChannelLabelForEvent(event) == desktopMoveInputChannelLabel + ? (_moveInputChannel ?? _inputChannel) + : _inputChannel; if (channel != null && channel.state == RTCDataChannelState.RTCDataChannelOpen) { final bufferedAmount = channel.bufferedAmount ?? 0; @@ -363,9 +396,11 @@ class DesktopClient { debugPrint('Desktop close request failed: $error'); } - await _dataChannel?.close(); + await _moveInputChannel?.close(); + await _inputChannel?.close(); await _peerConnection?.close(); - _dataChannel = null; + _moveInputChannel = null; + _inputChannel = null; _peerConnection = null; _stateController.add('disconnected'); } diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 76ae0c41..992e6ece 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -160,7 +160,7 @@ void main() { expect( desktopShouldDropInputEvent({ 'type': 'mouse_move', - }, bufferedAmount: 80 * 1024), + }, bufferedAmount: desktopMoveBufferedAmountLimit + 1), isTrue, ); expect( @@ -177,6 +177,36 @@ void main() { ); }); + test('routes mouse moves to the low-latency input channel', () { + expect( + desktopInputChannelLabelForEvent({'type': 'mouse_move'}), + desktopMoveInputChannelLabel, + ); + expect( + desktopInputChannelLabelForEvent({'type': 'mouse_down'}), + desktopReliableInputChannelLabel, + ); + expect( + desktopInputChannelLabelForEvent({'type': 'key_down'}), + desktopReliableInputChannelLabel, + ); + }); + + test('configures mouse move channel for low-latency delivery', () { + final reliableConfig = desktopReliableInputChannelConfig(); + final moveConfig = desktopMoveInputChannelConfig(); + + expect(reliableConfig.ordered, isTrue); + expect(reliableConfig.id, desktopReliableInputChannelId); + expect(moveConfig.ordered, isFalse); + expect(moveConfig.id, desktopMoveInputChannelId); + expect( + moveConfig.maxRetransmitTime, + desktopMoveChannelMaxPacketLifeTimeMs, + ); + expect(moveConfig.toMap()['maxPacketLifeTime'], 100); + }); + test('treats decoded video stats as a rendered first frame', () { expect( desktopHasRenderedVideoFrame(