diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 3ae097e5..5f265518 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -38,7 +38,6 @@ class DesktopClient { RTCPeerConnection? _peerConnection; RTCDataChannel? _dataChannel; - MediaStream? _remoteStream; final StreamController _streamController = StreamController.broadcast(); @@ -77,12 +76,14 @@ class DesktopClient { _peerConnection = await createPeerConnection(config); // Listen for remote streams - _peerConnection!.onTrack = (event) { + _peerConnection!.onTrack = (event) async { if (event.track.kind == 'video') { - if (event.streams.isNotEmpty) { - _remoteStream = event.streams.first; - _streamController.add(_remoteStream!); - } + final remoteStream = await desktopRemoteStreamFromTrack( + track: event.track, + streams: event.streams, + ); + if (remoteStream == null) return; + _streamController.add(remoteStream); } }; @@ -204,7 +205,27 @@ class DesktopClient { await _peerConnection?.close(); _dataChannel = null; _peerConnection = null; - _remoteStream = null; _stateController.add('disconnected'); } } + +Future desktopRemoteStreamFromTrack({ + required MediaStreamTrack track, + required List streams, + Future Function(String streamId)? streamFactory, +}) async { + if (track.kind != 'video') { + return null; + } + if (streams.isNotEmpty) { + return streams.first; + } + final factory = streamFactory ?? createLocalMediaStream; + final syntheticStream = await factory( + 'desktop-remote-${track.id}', + ); + // The bridge may deliver a bare video track without an attached stream. + // Wrapping it keeps RTCVideoView rendering instead of leaving the panel blank. + syntheticStream.addTrack(track); + return syntheticStream; +} diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 40f6722c..0f3d7708 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -3,6 +3,8 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:xworkmate/features/desktop/desktop_client.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('DesktopClient protocol helpers', () { test('normalizes WebRTC connection states for view gating', () { expect( @@ -42,5 +44,113 @@ void main() { expect(params['width'], 1280); expect(params['height'], 720); }); + + test('creates a synthetic media stream when the bridge omits streams', () async { + final track = _FakeMediaStreamTrack( + id: 'track-1', + kind: 'video', + ); + final createdStreams = <_FakeMediaStream>[]; + + final stream = await desktopRemoteStreamFromTrack( + track: track, + streams: const [], + streamFactory: (streamId) async { + final stream = _FakeMediaStream(streamId); + createdStreams.add(stream); + return stream; + }, + ); + + expect(stream, isNotNull); + expect(createdStreams, hasLength(1)); + expect(stream, same(createdStreams.single)); + expect(createdStreams.single.getVideoTracks(), hasLength(1)); + expect(createdStreams.single.getVideoTracks().single, same(track)); + }); + + test('prefers the attached remote stream when one is present', () async { + final track = _FakeMediaStreamTrack( + id: 'track-2', + kind: 'video', + ); + final attachedStream = _FakeMediaStream('attached-stream'); + + final stream = await desktopRemoteStreamFromTrack( + track: track, + streams: [attachedStream], + streamFactory: (streamId) async => _FakeMediaStream(streamId), + ); + + expect(stream, same(attachedStream)); + expect(stream!.getVideoTracks(), isEmpty); + }); }); } + +class _FakeMediaStreamTrack extends MediaStreamTrack { + _FakeMediaStreamTrack({required this.id, required this.kind}); + + @override + final String id; + + @override + final String kind; + + @override + String? get label => 'fake'; + + @override + bool get enabled => true; + + @override + set enabled(bool b) {} + + @override + bool? get muted => false; + + @override + Future stop() async {} + + @override + Future dispose() async {} +} + +class _FakeMediaStream extends MediaStream { + _FakeMediaStream(String id) : super(id, 'test-owner'); + + final List _tracks = []; + + @override + bool? get active => _tracks.isNotEmpty; + + @override + Future getMediaTracks() async {} + + @override + Future addTrack(MediaStreamTrack track, {bool addToNative = true}) async { + _tracks.add(track); + } + + @override + Future removeTrack( + MediaStreamTrack track, { + bool removeFromNative = true, + }) async { + _tracks.remove(track); + } + + @override + List getTracks() => List.unmodifiable(_tracks); + + @override + List getAudioTracks() => + _tracks.where((track) => track.kind == 'audio').toList(growable: false); + + @override + List getVideoTracks() => + _tracks.where((track) => track.kind == 'video').toList(growable: false); + + @override + Future clone() async => _FakeMediaStream('${id}_clone'); +}