fix desktop workspace stream fallback

This commit is contained in:
Cowork 3P 2026-06-04 15:45:59 +08:00
parent 6659a8057a
commit b2dda988a2
2 changed files with 138 additions and 7 deletions

View File

@ -38,7 +38,6 @@ class DesktopClient {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _dataChannel;
MediaStream? _remoteStream;
final StreamController<MediaStream> _streamController =
StreamController<MediaStream>.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<MediaStream?> desktopRemoteStreamFromTrack({
required MediaStreamTrack track,
required List<MediaStream> streams,
Future<MediaStream> 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;
}

View File

@ -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 <MediaStream>[],
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: <MediaStream>[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<void> stop() async {}
@override
Future<void> dispose() async {}
}
class _FakeMediaStream extends MediaStream {
_FakeMediaStream(String id) : super(id, 'test-owner');
final List<MediaStreamTrack> _tracks = <MediaStreamTrack>[];
@override
bool? get active => _tracks.isNotEmpty;
@override
Future<void> getMediaTracks() async {}
@override
Future<void> addTrack(MediaStreamTrack track, {bool addToNative = true}) async {
_tracks.add(track);
}
@override
Future<void> removeTrack(
MediaStreamTrack track, {
bool removeFromNative = true,
}) async {
_tracks.remove(track);
}
@override
List<MediaStreamTrack> getTracks() => List<MediaStreamTrack>.unmodifiable(_tracks);
@override
List<MediaStreamTrack> getAudioTracks() =>
_tracks.where((track) => track.kind == 'audio').toList(growable: false);
@override
List<MediaStreamTrack> getVideoTracks() =>
_tracks.where((track) => track.kind == 'video').toList(growable: false);
@override
Future<MediaStream> clone() async => _FakeMediaStream('${id}_clone');
}