diff --git a/android/app/build.gradle b/android/app/build.gradle index 57cc757..568b1ac 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,6 +26,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion 28 + ndkVersion "21.4.7075529" lintOptions { disable 'InvalidPackage' diff --git a/lib/src/call_sample/call_sample.dart b/lib/src/call_sample/call_sample.dart index ff20912..d0a289b 100644 --- a/lib/src/call_sample/call_sample.dart +++ b/lib/src/call_sample/call_sample.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:core'; +import '../widgets/screen_select_dialog.dart'; import 'signaling.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; @@ -136,15 +137,19 @@ class _CallSampleState extends State { title: Text("title"), content: Text("accept?"), actions: [ - TextButton( - child: Text("reject"), + MaterialButton( + child: Text( + 'Reject', + style: TextStyle(color: Colors.red), + ), onPressed: () => Navigator.of(context).pop(false), ), - TextButton( - child: Text("accept"), - onPressed: () { - Navigator.of(context).pop(true); - }, + MaterialButton( + child: Text( + 'Accept', + style: TextStyle(color: Colors.green), + ), + onPressed: () => Navigator.of(context).pop(true), ), ], ); @@ -201,6 +206,41 @@ class _CallSampleState extends State { _signaling?.switchCamera(); } + Future selectScreenSourceDialog(BuildContext context) async { + MediaStream? screenStream; + if (WebRTC.platformIsDesktop) { + final source = await showDialog( + context: context, + builder: (context) => ScreenSelectDialog(), + ); + if (source != null) { + try { + var stream = + await navigator.mediaDevices.getDisplayMedia({ + 'video': { + 'deviceId': {'exact': source.id}, + 'mandatory': {'frameRate': 30.0} + } + }); + stream.getVideoTracks()[0].onEnded = () { + print( + 'By adding a listener on onEnded you can: 1) catch stop video sharing on Web'); + }; + screenStream = stream; + } catch (e) { + print(e); + } + } + } else if (WebRTC.platformIsWeb) { + screenStream = + await navigator.mediaDevices.getDisplayMedia({ + 'audio': false, + 'video': true, + }); + } + if (screenStream != null) _signaling?.switchToScreenSharing(screenStream); + } + _muteMic() { _signaling?.muteMic(); } @@ -254,14 +294,20 @@ class _CallSampleState extends State { floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: _inCalling ? SizedBox( - width: 200.0, + width: 240.0, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ FloatingActionButton( child: const Icon(Icons.switch_camera), + tooltip: 'Camera', onPressed: _switchCamera, ), + FloatingActionButton( + child: const Icon(Icons.desktop_mac), + tooltip: 'Screen Sharing', + onPressed: () => selectScreenSourceDialog(context), + ), FloatingActionButton( onPressed: _hangUp, tooltip: 'Hangup', @@ -270,6 +316,7 @@ class _CallSampleState extends State { ), FloatingActionButton( child: const Icon(Icons.mic_off), + tooltip: 'Mute Mic', onPressed: _muteMic, ) ])) diff --git a/lib/src/call_sample/signaling.dart b/lib/src/call_sample/signaling.dart index 078b42e..f375889 100644 --- a/lib/src/call_sample/signaling.dart +++ b/lib/src/call_sample/signaling.dart @@ -26,6 +26,11 @@ enum CallState { CallStateBye, } +enum VideoSource { + Camera, + Screen, +} + class Session { Session({required this.sid, required this.pid}); String pid; @@ -49,6 +54,8 @@ class Signaling { Map _sessions = {}; MediaStream? _localStream; List _remoteStreams = []; + List _senders = []; + VideoSource _videoSource = VideoSource.Camera; Function(SignalingState state)? onSignalingStateChange; Function(Session session, CallState state)? onCallStateChange; @@ -60,8 +67,7 @@ class Signaling { onDataChannelMessage; Function(Session session, RTCDataChannel dc)? onDataChannel; - String get sdpSemantics => - WebRTC.platformIsWindows ? 'plan-b' : 'unified-plan'; + String get sdpSemantics => 'unified-plan'; Map _iceServers = { 'iceServers': [ @@ -99,7 +105,29 @@ class Signaling { void switchCamera() { if (_localStream != null) { - Helper.switchCamera(_localStream!.getVideoTracks()[0]); + if (_videoSource != VideoSource.Camera) { + _senders.forEach((sender) { + if (sender.track!.kind == 'video') { + sender.replaceTrack(_localStream!.getVideoTracks()[0]); + } + }); + _videoSource = VideoSource.Camera; + onLocalStream?.call(_localStream!); + } else { + Helper.switchCamera(_localStream!.getVideoTracks()[0]); + } + } + } + + void switchToScreenSharing(MediaStream stream) { + if (_localStream != null && _videoSource != VideoSource.Screen) { + _senders.forEach((sender) { + if (sender.track!.kind == 'video') { + sender.replaceTrack(stream.getVideoTracks()[0]); + } + }); + onLocalStream?.call(stream); + _videoSource = VideoSource.Screen; } } @@ -193,7 +221,6 @@ class Signaling { newSession.remoteCandidates.clear(); } onCallStateChange?.call(newSession, CallState.CallStateNew); - onCallStateChange?.call(newSession, CallState.CallStateRinging); } break; @@ -381,8 +408,8 @@ class Signaling { onAddRemoteStream?.call(newSession, event.streams[0]); } }; - _localStream!.getTracks().forEach((track) { - pc.addTrack(track, _localStream!); + _localStream!.getTracks().forEach((track) async { + _senders.add(await pc.addTrack(track, _localStream!)); }); break; } @@ -492,7 +519,7 @@ class Signaling { try { RTCSessionDescription s = await session.pc!.createOffer(media == 'data' ? _dcConstraints : {}); - await session.pc!.setLocalDescription(s); + await session.pc!.setLocalDescription(_fixSdp(s)); _send('offer', { 'to': session.pid, 'from': _selfId, @@ -505,11 +532,18 @@ class Signaling { } } + RTCSessionDescription _fixSdp(RTCSessionDescription s) { + var sdp = s.sdp; + s.sdp = + sdp!.replaceAll('profile-level-id=640c1f', 'profile-level-id=42e032'); + return s; + } + Future _createAnswer(Session session, String media) async { try { RTCSessionDescription s = await session.pc!.createAnswer(media == 'data' ? _dcConstraints : {}); - await session.pc!.setLocalDescription(s); + await session.pc!.setLocalDescription(_fixSdp(s)); _send('answer', { 'to': session.pid, 'from': _selfId, @@ -565,5 +599,7 @@ class Signaling { await session.pc?.close(); await session.dc?.close(); + _senders.clear(); + _videoSource = VideoSource.Camera; } } diff --git a/lib/src/widgets/screen_select_dialog.dart b/lib/src/widgets/screen_select_dialog.dart new file mode 100644 index 0000000..45a4f2a --- /dev/null +++ b/lib/src/widgets/screen_select_dialog.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class ThumbnailWidget extends StatefulWidget { + const ThumbnailWidget( + {Key? key, + required this.source, + required this.selected, + required this.onTap}) + : super(key: key); + final DesktopCapturerSource source; + final bool selected; + final Function(DesktopCapturerSource) onTap; + + @override + _ThumbnailWidgetState createState() => _ThumbnailWidgetState(); +} + +class _ThumbnailWidgetState extends State { + final List _subscriptions = []; + + @override + void initState() { + super.initState(); + _subscriptions.add(widget.source.onThumbnailChanged.stream.listen((event) { + setState(() {}); + })); + _subscriptions.add(widget.source.onNameChanged.stream.listen((event) { + setState(() {}); + })); + } + + @override + void deactivate() { + _subscriptions.forEach((element) { + element.cancel(); + }); + super.deactivate(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: Container( + decoration: widget.selected + ? BoxDecoration( + border: Border.all(width: 2, color: Colors.blueAccent)) + : null, + child: InkWell( + onTap: () { + print('Selected source id => ${widget.source.id}'); + widget.onTap(widget.source); + }, + child: widget.source.thumbnail != null + ? Image.memory( + widget.source.thumbnail!, + gaplessPlayback: true, + alignment: Alignment.center, + ) + : Container(), + ), + )), + Text( + widget.source.name, + style: TextStyle( + fontSize: 12, + color: Colors.black87, + fontWeight: + widget.selected ? FontWeight.bold : FontWeight.normal), + ), + ], + ); + } +} + +// ignore: must_be_immutable +class ScreenSelectDialog extends Dialog { + ScreenSelectDialog() { + Future.delayed(Duration(milliseconds: 100), () { + _getSources(); + }); + _subscriptions.add(desktopCapturer.onAdded.stream.listen((source) { + _sources[source.id] = source; + _stateSetter?.call(() {}); + })); + + _subscriptions.add(desktopCapturer.onRemoved.stream.listen((source) { + _sources.remove(source.id); + _stateSetter?.call(() {}); + })); + + _subscriptions + .add(desktopCapturer.onThumbnailChanged.stream.listen((source) { + _stateSetter?.call(() {}); + })); + } + final Map _sources = {}; + SourceType _sourceType = SourceType.Screen; + DesktopCapturerSource? _selected_source; + final List> _subscriptions = []; + StateSetter? _stateSetter; + Timer? _timer; + + void _ok(context) async { + _timer?.cancel(); + _subscriptions.forEach((element) { + element.cancel(); + }); + Navigator.pop(context, _selected_source); + } + + void _cancel(context) async { + _timer?.cancel(); + _subscriptions.forEach((element) { + element.cancel(); + }); + Navigator.pop(context, null); + } + + Future _getSources() async { + try { + var sources = await desktopCapturer.getSources(types: [_sourceType]); + sources.forEach((element) { + print( + 'name: ${element.name}, id: ${element.id}, type: ${element.type}'); + }); + _timer?.cancel(); + _timer = Timer.periodic(Duration(seconds: 3), (timer) { + desktopCapturer.updateSources(types: [_sourceType]); + }); + _sources.clear(); + sources.forEach((element) { + _sources[element.id] = element; + }); + _stateSetter?.call(() {}); + return; + } catch (e) { + print(e.toString()); + } + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + width: 640, + height: 560, + color: Colors.white, + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(10), + child: Stack( + children: [ + Align( + alignment: Alignment.topLeft, + child: Text( + 'Choose what to share', + style: TextStyle(fontSize: 16, color: Colors.black87), + ), + ), + Align( + alignment: Alignment.topRight, + child: InkWell( + child: Icon(Icons.close), + onTap: () => _cancel(context), + ), + ), + ], + ), + ), + Expanded( + flex: 1, + child: Container( + width: double.infinity, + padding: EdgeInsets.all(10), + child: StatefulBuilder( + builder: (context, setState) { + _stateSetter = setState; + return DefaultTabController( + length: 2, + child: Column( + children: [ + Container( + constraints: BoxConstraints.expand(height: 24), + child: TabBar( + onTap: (value) => Future.delayed( + Duration(milliseconds: 300), () { + _sourceType = value == 0 + ? SourceType.Screen + : SourceType.Window; + _getSources(); + }), + tabs: [ + Tab( + child: Text( + 'Entire Screen', + style: TextStyle(color: Colors.black54), + )), + Tab( + child: Text( + 'Window', + style: TextStyle(color: Colors.black54), + )), + ]), + ), + SizedBox( + height: 2, + ), + Expanded( + child: Container( + child: TabBarView(children: [ + Align( + alignment: Alignment.center, + child: Container( + child: GridView.count( + crossAxisSpacing: 8, + crossAxisCount: 2, + children: _sources.entries + .where((element) => + element.value.type == + SourceType.Screen) + .map((e) => ThumbnailWidget( + onTap: (source) { + setState(() { + _selected_source = source; + }); + }, + source: e.value, + selected: + _selected_source?.id == + e.value.id, + )) + .toList(), + ), + )), + Align( + alignment: Alignment.center, + child: Container( + child: GridView.count( + crossAxisSpacing: 8, + crossAxisCount: 3, + children: _sources.entries + .where((element) => + element.value.type == + SourceType.Window) + .map((e) => ThumbnailWidget( + onTap: (source) { + setState(() { + _selected_source = source; + }); + }, + source: e.value, + selected: + _selected_source?.id == + e.value.id, + )) + .toList(), + ), + )), + ]), + ), + ) + ], + ), + ); + }, + ), + ), + ), + Container( + width: double.infinity, + child: ButtonBar( + children: [ + MaterialButton( + child: Text( + 'Cancel', + style: TextStyle(color: Colors.black54), + ), + onPressed: () { + _cancel(context); + }, + ), + MaterialButton( + color: Theme.of(context).primaryColor, + child: Text( + 'Share', + ), + onPressed: () { + _ok(context); + }, + ), + ], + ), + ), + ], + ), + )), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 87a8009..b1c4a07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: sdk: flutter cupertino_icons: ^1.0.3 - flutter_webrtc: ^0.9.7 + flutter_webrtc: ^0.9.11 shared_preferences: ^2.0.7 http: ^0.13.3 path_provider: ^2.0.2