screen sharing for desktop. (#135)
* screen sharing for desktop (WIP). * use git repo for testing. * update. * Add sourceId/frameRate to ScreenShareCaptureOptions. * Compatible with flutter web. * Rendering of camera and screenshare video tracks at same time. * Remote screen sharing is shown first, and fixed sub/unsub button for screenshare widget. * resolved conflict. * modify flutter-webrtc to pub version. * remove unused import. * chore: Common params for Camera and ScreenShare.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
|
||||
import '../logger.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class ScreenSelectDialog extends Dialog {
|
||||
ScreenSelectDialog({Key? key}) : super(key: key) {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_getSources();
|
||||
});
|
||||
_subscriptions.add(rtc.desktopCapturer.onAdded.stream.listen((source) {
|
||||
_sources[source.id] = source;
|
||||
_stateSetter?.call(() {});
|
||||
}));
|
||||
|
||||
_subscriptions.add(rtc.desktopCapturer.onRemoved.stream.listen((source) {
|
||||
_sources.remove(source.id);
|
||||
_stateSetter?.call(() {});
|
||||
}));
|
||||
|
||||
_subscriptions
|
||||
.add(rtc.desktopCapturer.onNameChanged.stream.listen((source) {
|
||||
_sources[source.id] = source;
|
||||
_stateSetter?.call(() {});
|
||||
}));
|
||||
|
||||
_subscriptions
|
||||
.add(rtc.desktopCapturer.onThumbnailChanged.stream.listen((source) {
|
||||
_sources[source.id] = source;
|
||||
_stateSetter?.call(() {});
|
||||
}));
|
||||
}
|
||||
final Map<String, rtc.DesktopCapturerSource> _sources = {};
|
||||
rtc.SourceType _sourceType = rtc.SourceType.Screen;
|
||||
rtc.DesktopCapturerSource? _selectedSource;
|
||||
final List<StreamSubscription<rtc.DesktopCapturerSource>> _subscriptions = [];
|
||||
StateSetter? _stateSetter;
|
||||
Timer? _timer;
|
||||
|
||||
void _ok(BuildContext context) {
|
||||
_timer?.cancel();
|
||||
for (var item in _subscriptions) {
|
||||
item.cancel();
|
||||
}
|
||||
Navigator.pop<rtc.DesktopCapturerSource>(context, _selectedSource);
|
||||
}
|
||||
|
||||
void _cancel(BuildContext context) {
|
||||
_timer?.cancel();
|
||||
for (var item in _subscriptions) {
|
||||
item.cancel();
|
||||
}
|
||||
Navigator.pop<rtc.DesktopCapturerSource>(context, null);
|
||||
}
|
||||
|
||||
Future<void> _getSources() async {
|
||||
try {
|
||||
var sources = await rtc.desktopCapturer.getSources(types: [_sourceType]);
|
||||
for (var item in sources) {
|
||||
logger.info('name: ${item.name}, id: ${item.id}, type: ${item.type}');
|
||||
}
|
||||
_stateSetter?.call(() {
|
||||
for (var item in sources) {
|
||||
_sources[item.id] = item;
|
||||
}
|
||||
});
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (timer) {
|
||||
rtc.desktopCapturer.updateSources(types: [_sourceType]);
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.warning(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: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
const Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
'Choose what to share',
|
||||
style: TextStyle(fontSize: 16, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: InkWell(
|
||||
child: const Icon(Icons.close),
|
||||
onTap: () => _cancel(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
_stateSetter = setState;
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
constraints:
|
||||
const BoxConstraints.expand(height: 24),
|
||||
child: TabBar(
|
||||
onTap: (value) => Future.delayed(
|
||||
const Duration(milliseconds: 300), () {
|
||||
_sourceType = value == 0
|
||||
? rtc.SourceType.Screen
|
||||
: rtc.SourceType.Window;
|
||||
_getSources();
|
||||
}),
|
||||
tabs: const [
|
||||
Tab(
|
||||
child: Text(
|
||||
'Entire Screen',
|
||||
style: TextStyle(color: Colors.black54),
|
||||
)),
|
||||
Tab(
|
||||
child: Text(
|
||||
'Window',
|
||||
style: TextStyle(color: Colors.black54),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 2,
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: GridView.count(
|
||||
crossAxisSpacing: 8,
|
||||
crossAxisCount: 2,
|
||||
children: _sources.entries
|
||||
.where((element) =>
|
||||
element.value.type ==
|
||||
rtc.SourceType.Screen)
|
||||
.map((e) => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: (_selectedSource !=
|
||||
null &&
|
||||
_selectedSource!.id ==
|
||||
e.value.id)
|
||||
? BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: Colors
|
||||
.blueAccent))
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
logger.info(
|
||||
'Selected screen id => ${e.value.id}');
|
||||
setState(() {
|
||||
_selectedSource =
|
||||
e.value;
|
||||
});
|
||||
},
|
||||
child: e.value.thumbnail !=
|
||||
null
|
||||
? Image.memory(
|
||||
e.value.thumbnail!,
|
||||
scale: 1.0,
|
||||
repeat: ImageRepeat
|
||||
.noRepeat,
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
)),
|
||||
Text(
|
||||
e.value.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black87,
|
||||
fontWeight:
|
||||
(_selectedSource !=
|
||||
null &&
|
||||
_selectedSource!
|
||||
.id ==
|
||||
e.value
|
||||
.id)
|
||||
? FontWeight.bold
|
||||
: FontWeight
|
||||
.normal),
|
||||
),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: GridView.count(
|
||||
crossAxisSpacing: 8,
|
||||
crossAxisCount: 3,
|
||||
children: _sources.entries
|
||||
.where((element) =>
|
||||
element.value.type ==
|
||||
rtc.SourceType.Window)
|
||||
.map((e) => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: (_selectedSource !=
|
||||
null &&
|
||||
_selectedSource!.id ==
|
||||
e.value.id)
|
||||
? BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: Colors
|
||||
.blueAccent))
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
logger.info(
|
||||
'Selected window id => ${e.value.id}');
|
||||
setState(() {
|
||||
_selectedSource =
|
||||
e.value;
|
||||
});
|
||||
},
|
||||
child: e.value.thumbnail!
|
||||
.isNotEmpty
|
||||
? Image.memory(
|
||||
e.value.thumbnail!,
|
||||
scale: 1.0,
|
||||
repeat: ImageRepeat
|
||||
.noRepeat,
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
)),
|
||||
Text(
|
||||
e.value.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black87,
|
||||
fontWeight:
|
||||
(_selectedSource !=
|
||||
null &&
|
||||
_selectedSource!
|
||||
.id ==
|
||||
e.value
|
||||
.id)
|
||||
? FontWeight.bold
|
||||
: FontWeight
|
||||
.normal),
|
||||
),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
]),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonBar(
|
||||
children: <Widget>[
|
||||
MaterialButton(
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: Colors.black54),
|
||||
),
|
||||
onPressed: () {
|
||||
_cancel(context);
|
||||
},
|
||||
),
|
||||
MaterialButton(
|
||||
color: Theme.of(context).primaryColor,
|
||||
child: const Text(
|
||||
'Share',
|
||||
),
|
||||
onPressed: () {
|
||||
_ok(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
|
||||
import '../events.dart';
|
||||
import '../extensions.dart';
|
||||
import '../internal/events.dart';
|
||||
import '../managers/event.dart';
|
||||
import '../track/local/local.dart';
|
||||
import '../track/local/video.dart';
|
||||
import '../track/options.dart';
|
||||
|
||||
enum VideoViewMirrorMode {
|
||||
auto,
|
||||
off,
|
||||
mirror,
|
||||
}
|
||||
|
||||
/// Widget that renders a [VideoTrack].
|
||||
class VideoTrackRenderer extends StatefulWidget {
|
||||
final VideoTrack track;
|
||||
final rtc.RTCVideoViewObjectFit fit;
|
||||
final VideoViewMirrorMode mirrorMode;
|
||||
|
||||
const VideoTrackRenderer(
|
||||
this.track, {
|
||||
this.fit = rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
||||
this.mirrorMode = VideoViewMirrorMode.auto,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _VideoTrackRendererState();
|
||||
}
|
||||
|
||||
class _VideoTrackRendererState extends State<VideoTrackRenderer> {
|
||||
final _renderer = rtc.RTCVideoRenderer();
|
||||
bool _rendererReady = false;
|
||||
EventsListener<TrackEvent>? _listener;
|
||||
// Used to compute visibility information
|
||||
late GlobalKey _internalKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_internalKey = widget.track.addViewKey();
|
||||
|
||||
(() async {
|
||||
await _renderer.initialize();
|
||||
await _attach();
|
||||
setState(() => _rendererReady = true);
|
||||
})();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.track.removeViewKey(_internalKey);
|
||||
_listener?.dispose();
|
||||
_renderer.srcObject = null;
|
||||
_renderer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _attach() async {
|
||||
_renderer.srcObject = widget.track.mediaStream;
|
||||
await _listener?.dispose();
|
||||
_listener = widget.track.createListener()
|
||||
..on<TrackStreamUpdatedEvent>((event) {
|
||||
if (!mounted) return;
|
||||
_renderer.srcObject = event.stream;
|
||||
})
|
||||
..on<LocalTrackOptionsUpdatedEvent>((event) {
|
||||
if (!mounted) return;
|
||||
// force recompute of mirror mode
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VideoTrackRenderer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
//
|
||||
if (widget.track != oldWidget.track) {
|
||||
oldWidget.track.removeViewKey(_internalKey);
|
||||
_internalKey = widget.track.addViewKey();
|
||||
// TODO: re-attach only if needed
|
||||
(() async {
|
||||
await _attach();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => !_rendererReady
|
||||
? Container()
|
||||
: Builder(
|
||||
key: _internalKey,
|
||||
builder: (ctx) {
|
||||
// let it render before notifying build
|
||||
WidgetsBindingCompatible.instance
|
||||
?.addPostFrameCallback((timeStamp) {
|
||||
widget.track.onVideoViewBuild?.call(_internalKey);
|
||||
});
|
||||
return rtc.RTCVideoView(
|
||||
_renderer,
|
||||
mirror: _shouldMirror(),
|
||||
filterQuality: FilterQuality.medium,
|
||||
objectFit: widget.fit,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
bool _shouldMirror() {
|
||||
// on
|
||||
if (widget.mirrorMode == VideoViewMirrorMode.mirror) return true;
|
||||
// auto
|
||||
if (widget.mirrorMode == VideoViewMirrorMode.auto) {
|
||||
final track = widget.track;
|
||||
if (track is LocalVideoTrack) {
|
||||
final options = track.currentOptions;
|
||||
if (options is CameraCaptureOptions) {
|
||||
// mirror if front camera
|
||||
return options.cameraPosition == CameraPosition.front;
|
||||
}
|
||||
}
|
||||
}
|
||||
// default to false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user