Skip to content

Commit a6f4958

Browse files
authoredOct 21, 2022
Merge pull request #200 from flutter-webrtc/feat/desktop-capture-testing
Screen sharing supports
2 parents 45be971 + a92cc75 commit a6f4958

File tree

5 files changed

+408
-17
lines changed

5 files changed

+408
-17
lines changed
 

‎android/app/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
2626

2727
android {
2828
compileSdkVersion 28
29+
ndkVersion "21.4.7075529"
2930

3031
lintOptions {
3132
disable 'InvalidPackage'

‎lib/src/call_sample/call_sample.dart

+55-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'dart:core';
3+
import '../widgets/screen_select_dialog.dart';
34
import 'signaling.dart';
45
import 'package:flutter_webrtc/flutter_webrtc.dart';
56

@@ -136,15 +137,19 @@ class _CallSampleState extends State<CallSample> {
136137
title: Text("title"),
137138
content: Text("accept?"),
138139
actions: <Widget>[
139-
TextButton(
140-
child: Text("reject"),
140+
MaterialButton(
141+
child: Text(
142+
'Reject',
143+
style: TextStyle(color: Colors.red),
144+
),
141145
onPressed: () => Navigator.of(context).pop(false),
142146
),
143-
TextButton(
144-
child: Text("accept"),
145-
onPressed: () {
146-
Navigator.of(context).pop(true);
147-
},
147+
MaterialButton(
148+
child: Text(
149+
'Accept',
150+
style: TextStyle(color: Colors.green),
151+
),
152+
onPressed: () => Navigator.of(context).pop(true),
148153
),
149154
],
150155
);
@@ -201,6 +206,41 @@ class _CallSampleState extends State<CallSample> {
201206
_signaling?.switchCamera();
202207
}
203208

209+
Future<void> selectScreenSourceDialog(BuildContext context) async {
210+
MediaStream? screenStream;
211+
if (WebRTC.platformIsDesktop) {
212+
final source = await showDialog<DesktopCapturerSource>(
213+
context: context,
214+
builder: (context) => ScreenSelectDialog(),
215+
);
216+
if (source != null) {
217+
try {
218+
var stream =
219+
await navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
220+
'video': {
221+
'deviceId': {'exact': source.id},
222+
'mandatory': {'frameRate': 30.0}
223+
}
224+
});
225+
stream.getVideoTracks()[0].onEnded = () {
226+
print(
227+
'By adding a listener on onEnded you can: 1) catch stop video sharing on Web');
228+
};
229+
screenStream = stream;
230+
} catch (e) {
231+
print(e);
232+
}
233+
}
234+
} else if (WebRTC.platformIsWeb) {
235+
screenStream =
236+
await navigator.mediaDevices.getDisplayMedia(<String, dynamic>{
237+
'audio': false,
238+
'video': true,
239+
});
240+
}
241+
if (screenStream != null) _signaling?.switchToScreenSharing(screenStream);
242+
}
243+
204244
_muteMic() {
205245
_signaling?.muteMic();
206246
}
@@ -254,14 +294,20 @@ class _CallSampleState extends State<CallSample> {
254294
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
255295
floatingActionButton: _inCalling
256296
? SizedBox(
257-
width: 200.0,
297+
width: 240.0,
258298
child: Row(
259299
mainAxisAlignment: MainAxisAlignment.spaceBetween,
260300
children: <Widget>[
261301
FloatingActionButton(
262302
child: const Icon(Icons.switch_camera),
303+
tooltip: 'Camera',
263304
onPressed: _switchCamera,
264305
),
306+
FloatingActionButton(
307+
child: const Icon(Icons.desktop_mac),
308+
tooltip: 'Screen Sharing',
309+
onPressed: () => selectScreenSourceDialog(context),
310+
),
265311
FloatingActionButton(
266312
onPressed: _hangUp,
267313
tooltip: 'Hangup',
@@ -270,6 +316,7 @@ class _CallSampleState extends State<CallSample> {
270316
),
271317
FloatingActionButton(
272318
child: const Icon(Icons.mic_off),
319+
tooltip: 'Mute Mic',
273320
onPressed: _muteMic,
274321
)
275322
]))

‎lib/src/call_sample/signaling.dart

+44-8
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ enum CallState {
2626
CallStateBye,
2727
}
2828

29+
enum VideoSource {
30+
Camera,
31+
Screen,
32+
}
33+
2934
class Session {
3035
Session({required this.sid, required this.pid});
3136
String pid;
@@ -49,6 +54,8 @@ class Signaling {
4954
Map<String, Session> _sessions = {};
5055
MediaStream? _localStream;
5156
List<MediaStream> _remoteStreams = <MediaStream>[];
57+
List<RTCRtpSender> _senders = <RTCRtpSender>[];
58+
VideoSource _videoSource = VideoSource.Camera;
5259

5360
Function(SignalingState state)? onSignalingStateChange;
5461
Function(Session session, CallState state)? onCallStateChange;
@@ -60,8 +67,7 @@ class Signaling {
6067
onDataChannelMessage;
6168
Function(Session session, RTCDataChannel dc)? onDataChannel;
6269

63-
String get sdpSemantics =>
64-
WebRTC.platformIsWindows ? 'plan-b' : 'unified-plan';
70+
String get sdpSemantics => 'unified-plan';
6571

6672
Map<String, dynamic> _iceServers = {
6773
'iceServers': [
@@ -99,7 +105,29 @@ class Signaling {
99105

100106
void switchCamera() {
101107
if (_localStream != null) {
102-
Helper.switchCamera(_localStream!.getVideoTracks()[0]);
108+
if (_videoSource != VideoSource.Camera) {
109+
_senders.forEach((sender) {
110+
if (sender.track!.kind == 'video') {
111+
sender.replaceTrack(_localStream!.getVideoTracks()[0]);
112+
}
113+
});
114+
_videoSource = VideoSource.Camera;
115+
onLocalStream?.call(_localStream!);
116+
} else {
117+
Helper.switchCamera(_localStream!.getVideoTracks()[0]);
118+
}
119+
}
120+
}
121+
122+
void switchToScreenSharing(MediaStream stream) {
123+
if (_localStream != null && _videoSource != VideoSource.Screen) {
124+
_senders.forEach((sender) {
125+
if (sender.track!.kind == 'video') {
126+
sender.replaceTrack(stream.getVideoTracks()[0]);
127+
}
128+
});
129+
onLocalStream?.call(stream);
130+
_videoSource = VideoSource.Screen;
103131
}
104132
}
105133

@@ -193,7 +221,6 @@ class Signaling {
193221
newSession.remoteCandidates.clear();
194222
}
195223
onCallStateChange?.call(newSession, CallState.CallStateNew);
196-
197224
onCallStateChange?.call(newSession, CallState.CallStateRinging);
198225
}
199226
break;
@@ -381,8 +408,8 @@ class Signaling {
381408
onAddRemoteStream?.call(newSession, event.streams[0]);
382409
}
383410
};
384-
_localStream!.getTracks().forEach((track) {
385-
pc.addTrack(track, _localStream!);
411+
_localStream!.getTracks().forEach((track) async {
412+
_senders.add(await pc.addTrack(track, _localStream!));
386413
});
387414
break;
388415
}
@@ -492,7 +519,7 @@ class Signaling {
492519
try {
493520
RTCSessionDescription s =
494521
await session.pc!.createOffer(media == 'data' ? _dcConstraints : {});
495-
await session.pc!.setLocalDescription(s);
522+
await session.pc!.setLocalDescription(_fixSdp(s));
496523
_send('offer', {
497524
'to': session.pid,
498525
'from': _selfId,
@@ -505,11 +532,18 @@ class Signaling {
505532
}
506533
}
507534

535+
RTCSessionDescription _fixSdp(RTCSessionDescription s) {
536+
var sdp = s.sdp;
537+
s.sdp =
538+
sdp!.replaceAll('profile-level-id=640c1f', 'profile-level-id=42e032');
539+
return s;
540+
}
541+
508542
Future<void> _createAnswer(Session session, String media) async {
509543
try {
510544
RTCSessionDescription s =
511545
await session.pc!.createAnswer(media == 'data' ? _dcConstraints : {});
512-
await session.pc!.setLocalDescription(s);
546+
await session.pc!.setLocalDescription(_fixSdp(s));
513547
_send('answer', {
514548
'to': session.pid,
515549
'from': _selfId,
@@ -565,5 +599,7 @@ class Signaling {
565599

566600
await session.pc?.close();
567601
await session.dc?.close();
602+
_senders.clear();
603+
_videoSource = VideoSource.Camera;
568604
}
569605
}
+307
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_webrtc/flutter_webrtc.dart';
5+
6+
class ThumbnailWidget extends StatefulWidget {
7+
const ThumbnailWidget(
8+
{Key? key,
9+
required this.source,
10+
required this.selected,
11+
required this.onTap})
12+
: super(key: key);
13+
final DesktopCapturerSource source;
14+
final bool selected;
15+
final Function(DesktopCapturerSource) onTap;
16+
17+
@override
18+
_ThumbnailWidgetState createState() => _ThumbnailWidgetState();
19+
}
20+
21+
class _ThumbnailWidgetState extends State<ThumbnailWidget> {
22+
final List<StreamSubscription> _subscriptions = [];
23+
24+
@override
25+
void initState() {
26+
super.initState();
27+
_subscriptions.add(widget.source.onThumbnailChanged.stream.listen((event) {
28+
setState(() {});
29+
}));
30+
_subscriptions.add(widget.source.onNameChanged.stream.listen((event) {
31+
setState(() {});
32+
}));
33+
}
34+
35+
@override
36+
void deactivate() {
37+
_subscriptions.forEach((element) {
38+
element.cancel();
39+
});
40+
super.deactivate();
41+
}
42+
43+
@override
44+
Widget build(BuildContext context) {
45+
return Column(
46+
children: [
47+
Expanded(
48+
child: Container(
49+
decoration: widget.selected
50+
? BoxDecoration(
51+
border: Border.all(width: 2, color: Colors.blueAccent))
52+
: null,
53+
child: InkWell(
54+
onTap: () {
55+
print('Selected source id => ${widget.source.id}');
56+
widget.onTap(widget.source);
57+
},
58+
child: widget.source.thumbnail != null
59+
? Image.memory(
60+
widget.source.thumbnail!,
61+
gaplessPlayback: true,
62+
alignment: Alignment.center,
63+
)
64+
: Container(),
65+
),
66+
)),
67+
Text(
68+
widget.source.name,
69+
style: TextStyle(
70+
fontSize: 12,
71+
color: Colors.black87,
72+
fontWeight:
73+
widget.selected ? FontWeight.bold : FontWeight.normal),
74+
),
75+
],
76+
);
77+
}
78+
}
79+
80+
// ignore: must_be_immutable
81+
class ScreenSelectDialog extends Dialog {
82+
ScreenSelectDialog() {
83+
Future.delayed(Duration(milliseconds: 100), () {
84+
_getSources();
85+
});
86+
_subscriptions.add(desktopCapturer.onAdded.stream.listen((source) {
87+
_sources[source.id] = source;
88+
_stateSetter?.call(() {});
89+
}));
90+
91+
_subscriptions.add(desktopCapturer.onRemoved.stream.listen((source) {
92+
_sources.remove(source.id);
93+
_stateSetter?.call(() {});
94+
}));
95+
96+
_subscriptions
97+
.add(desktopCapturer.onThumbnailChanged.stream.listen((source) {
98+
_stateSetter?.call(() {});
99+
}));
100+
}
101+
final Map<String, DesktopCapturerSource> _sources = {};
102+
SourceType _sourceType = SourceType.Screen;
103+
DesktopCapturerSource? _selected_source;
104+
final List<StreamSubscription<DesktopCapturerSource>> _subscriptions = [];
105+
StateSetter? _stateSetter;
106+
Timer? _timer;
107+
108+
void _ok(context) async {
109+
_timer?.cancel();
110+
_subscriptions.forEach((element) {
111+
element.cancel();
112+
});
113+
Navigator.pop<DesktopCapturerSource>(context, _selected_source);
114+
}
115+
116+
void _cancel(context) async {
117+
_timer?.cancel();
118+
_subscriptions.forEach((element) {
119+
element.cancel();
120+
});
121+
Navigator.pop<DesktopCapturerSource>(context, null);
122+
}
123+
124+
Future<void> _getSources() async {
125+
try {
126+
var sources = await desktopCapturer.getSources(types: [_sourceType]);
127+
sources.forEach((element) {
128+
print(
129+
'name: ${element.name}, id: ${element.id}, type: ${element.type}');
130+
});
131+
_timer?.cancel();
132+
_timer = Timer.periodic(Duration(seconds: 3), (timer) {
133+
desktopCapturer.updateSources(types: [_sourceType]);
134+
});
135+
_sources.clear();
136+
sources.forEach((element) {
137+
_sources[element.id] = element;
138+
});
139+
_stateSetter?.call(() {});
140+
return;
141+
} catch (e) {
142+
print(e.toString());
143+
}
144+
}
145+
146+
@override
147+
Widget build(BuildContext context) {
148+
return Material(
149+
type: MaterialType.transparency,
150+
child: Center(
151+
child: Container(
152+
width: 640,
153+
height: 560,
154+
color: Colors.white,
155+
child: Column(
156+
children: <Widget>[
157+
Padding(
158+
padding: EdgeInsets.all(10),
159+
child: Stack(
160+
children: <Widget>[
161+
Align(
162+
alignment: Alignment.topLeft,
163+
child: Text(
164+
'Choose what to share',
165+
style: TextStyle(fontSize: 16, color: Colors.black87),
166+
),
167+
),
168+
Align(
169+
alignment: Alignment.topRight,
170+
child: InkWell(
171+
child: Icon(Icons.close),
172+
onTap: () => _cancel(context),
173+
),
174+
),
175+
],
176+
),
177+
),
178+
Expanded(
179+
flex: 1,
180+
child: Container(
181+
width: double.infinity,
182+
padding: EdgeInsets.all(10),
183+
child: StatefulBuilder(
184+
builder: (context, setState) {
185+
_stateSetter = setState;
186+
return DefaultTabController(
187+
length: 2,
188+
child: Column(
189+
children: <Widget>[
190+
Container(
191+
constraints: BoxConstraints.expand(height: 24),
192+
child: TabBar(
193+
onTap: (value) => Future.delayed(
194+
Duration(milliseconds: 300), () {
195+
_sourceType = value == 0
196+
? SourceType.Screen
197+
: SourceType.Window;
198+
_getSources();
199+
}),
200+
tabs: [
201+
Tab(
202+
child: Text(
203+
'Entire Screen',
204+
style: TextStyle(color: Colors.black54),
205+
)),
206+
Tab(
207+
child: Text(
208+
'Window',
209+
style: TextStyle(color: Colors.black54),
210+
)),
211+
]),
212+
),
213+
SizedBox(
214+
height: 2,
215+
),
216+
Expanded(
217+
child: Container(
218+
child: TabBarView(children: [
219+
Align(
220+
alignment: Alignment.center,
221+
child: Container(
222+
child: GridView.count(
223+
crossAxisSpacing: 8,
224+
crossAxisCount: 2,
225+
children: _sources.entries
226+
.where((element) =>
227+
element.value.type ==
228+
SourceType.Screen)
229+
.map((e) => ThumbnailWidget(
230+
onTap: (source) {
231+
setState(() {
232+
_selected_source = source;
233+
});
234+
},
235+
source: e.value,
236+
selected:
237+
_selected_source?.id ==
238+
e.value.id,
239+
))
240+
.toList(),
241+
),
242+
)),
243+
Align(
244+
alignment: Alignment.center,
245+
child: Container(
246+
child: GridView.count(
247+
crossAxisSpacing: 8,
248+
crossAxisCount: 3,
249+
children: _sources.entries
250+
.where((element) =>
251+
element.value.type ==
252+
SourceType.Window)
253+
.map((e) => ThumbnailWidget(
254+
onTap: (source) {
255+
setState(() {
256+
_selected_source = source;
257+
});
258+
},
259+
source: e.value,
260+
selected:
261+
_selected_source?.id ==
262+
e.value.id,
263+
))
264+
.toList(),
265+
),
266+
)),
267+
]),
268+
),
269+
)
270+
],
271+
),
272+
);
273+
},
274+
),
275+
),
276+
),
277+
Container(
278+
width: double.infinity,
279+
child: ButtonBar(
280+
children: <Widget>[
281+
MaterialButton(
282+
child: Text(
283+
'Cancel',
284+
style: TextStyle(color: Colors.black54),
285+
),
286+
onPressed: () {
287+
_cancel(context);
288+
},
289+
),
290+
MaterialButton(
291+
color: Theme.of(context).primaryColor,
292+
child: Text(
293+
'Share',
294+
),
295+
onPressed: () {
296+
_ok(context);
297+
},
298+
),
299+
],
300+
),
301+
),
302+
],
303+
),
304+
)),
305+
);
306+
}
307+
}

‎pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ dependencies:
1111
sdk: flutter
1212

1313
cupertino_icons: ^1.0.3
14-
flutter_webrtc: ^0.9.7
14+
flutter_webrtc: ^0.9.11
1515
shared_preferences: ^2.0.7
1616
http: ^0.13.3
1717
path_provider: ^2.0.2

0 commit comments

Comments
 (0)
Please sign in to comment.