diff --git a/example/lib/pages/connect.dart b/example/lib/pages/connect.dart index 89f9d669..ff0695ee 100644 --- a/example/lib/pages/connect.dart +++ b/example/lib/pages/connect.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_example/pages/prejoin.dart'; import 'package:livekit_example/widgets/text_field.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:permission_handler/permission_handler.dart'; import '../exts.dart'; -import 'room.dart'; class ConnectPage extends StatefulWidget { // @@ -25,7 +25,6 @@ class _ConnectPageState extends State { static const _storeKeySimulcast = 'simulcast'; static const _storeKeyAdaptiveStream = 'adaptive-stream'; static const _storeKeyDynacast = 'dynacast'; - static const _storeKeyFastConnect = 'fast-connect'; static const _storeKeyE2EE = 'e2ee'; static const _storeKeySharedKey = 'shared-key'; static const _storeKeyMultiCodec = 'multi-codec'; @@ -37,7 +36,6 @@ class _ConnectPageState extends State { bool _adaptiveStream = true; bool _dynacast = true; bool _busy = false; - bool _fastConnect = false; bool _e2ee = false; bool _multiCodec = false; String _preferredCodec = 'Preferred Codec'; @@ -96,7 +94,6 @@ class _ConnectPageState extends State { _simulcast = prefs.getBool(_storeKeySimulcast) ?? true; _adaptiveStream = prefs.getBool(_storeKeyAdaptiveStream) ?? true; _dynacast = prefs.getBool(_storeKeyDynacast) ?? true; - _fastConnect = prefs.getBool(_storeKeyFastConnect) ?? false; _e2ee = prefs.getBool(_storeKeyE2EE) ?? false; _multiCodec = prefs.getBool(_storeKeyMultiCodec) ?? false; }); @@ -111,7 +108,6 @@ class _ConnectPageState extends State { await prefs.setBool(_storeKeySimulcast, _simulcast); await prefs.setBool(_storeKeyAdaptiveStream, _adaptiveStream); await prefs.setBool(_storeKeyDynacast, _dynacast); - await prefs.setBool(_storeKeyFastConnect, _fastConnect); await prefs.setBool(_storeKeyE2EE, _e2ee); await prefs.setBool(_storeKeyMultiCodec, _multiCodec); } @@ -129,65 +125,27 @@ class _ConnectPageState extends State { print('Connecting with url: ${_uriCtrl.text}, ' 'token: ${_tokenCtrl.text}...'); - E2EEOptions? e2eeOptions; - if (_e2ee) { - final keyProvider = await BaseKeyProvider.create(); - e2eeOptions = E2EEOptions(keyProvider: keyProvider); - var sharedKey = _sharedKeyCtrl.text; - await keyProvider.setSharedKey(sharedKey); - } - - String preferredCodec = 'VP8'; - if (_preferredCodec != 'Preferred Codec') { - preferredCodec = _preferredCodec; - } - - bool enableBackupVideoCodec = ['VP9', 'AV1'].contains(preferredCodec); - - // create new room - final room = Room( - roomOptions: RoomOptions( - adaptiveStream: _adaptiveStream, - dynacast: _dynacast, - defaultAudioPublishOptions: const AudioPublishOptions( - dtx: true, - ), - defaultVideoPublishOptions: VideoPublishOptions( - simulcast: _simulcast, - videoCodec: preferredCodec, - backupVideoCodec: BackupVideoCodec( - enabled: enableBackupVideoCodec, - ), - ), - defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( - useiOSBroadcastExtension: true, - params: VideoParametersPresets.screenShareH1080FPS30), - e2eeOptions: e2eeOptions, - defaultCameraCaptureOptions: const CameraCaptureOptions( - maxFrameRate: 30, - params: VideoParametersPresets.h720_169, - ), - )); - - // Create a Listener before connecting - final listener = room.createListener(); - - // Try to connect to the room - // This will throw an Exception if it fails for any reason. - await room.connect( - _uriCtrl.text, - _tokenCtrl.text, - fastConnectOptions: _fastConnect - ? FastConnectOptions( - microphone: const TrackOption(enabled: true), - camera: const TrackOption(enabled: true), - ) - : null, - ); + var url = _uriCtrl.text; + var token = _tokenCtrl.text; + var e2eeKey = _sharedKeyCtrl.text; await Navigator.push( ctx, - MaterialPageRoute(builder: (_) => RoomPage(room, listener)), + MaterialPageRoute( + builder: (_) => PreJoinPage( + args: JoinArgs( + url: url, + token: token, + e2ee: _e2ee, + e2eeKey: e2eeKey, + simulcast: _simulcast, + adaptiveStream: _adaptiveStream, + dynacast: _dynacast, + preferredCodec: _preferredCodec, + enableBackupVideoCodec: + ['VP9', 'AV1'].contains(_preferredCodec), + ), + )), ); } catch (error) { print('Could not connect $error'); @@ -227,13 +185,6 @@ class _ConnectPageState extends State { }); } - void _setFastConnect(bool? value) async { - if (value == null || _fastConnect == value) return; - setState(() { - _fastConnect = value; - }); - } - void _setMultiCodec(bool? value) async { if (value == null || _multiCodec == value) return; setState(() { @@ -322,19 +273,6 @@ class _ConnectPageState extends State { ], ), ), - Padding( - padding: const EdgeInsets.only(bottom: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Fast Connect'), - Switch( - value: _fastConnect, - onChanged: (value) => _setFastConnect(value), - ), - ], - ), - ), Padding( padding: const EdgeInsets.only(bottom: 5), child: Row( diff --git a/example/lib/pages/prejoin.dart b/example/lib/pages/prejoin.dart new file mode 100644 index 00000000..6b832645 --- /dev/null +++ b/example/lib/pages/prejoin.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_example/exts.dart'; + +import '../theme.dart'; +import 'room.dart'; + +class JoinArgs { + JoinArgs({ + required this.url, + required this.token, + this.e2ee = false, + this.e2eeKey, + this.simulcast = true, + this.adaptiveStream = true, + this.dynacast = true, + this.preferredCodec = 'VP8', + this.enableBackupVideoCodec = true, + }); + final String url; + final String token; + final bool e2ee; + final String? e2eeKey; + final bool simulcast; + final bool adaptiveStream; + final bool dynacast; + final String preferredCodec; + final bool enableBackupVideoCodec; +} + +class PreJoinPage extends StatefulWidget { + const PreJoinPage({ + required this.args, + Key? key, + }) : super(key: key); + final JoinArgs args; + @override + State createState() => _PreJoinPageState(); +} + +class _PreJoinPageState extends State { + List _audioInputs = []; + List _videoInputs = []; + StreamSubscription? _subscription; + + bool _busy = false; + bool _enableVideo = true; + bool _enableAudio = true; + LocalAudioTrack? _audioTrack; + LocalVideoTrack? _videoTrack; + + MediaDevice? _selectedVideoDevice; + MediaDevice? _selectedAudioDevice; + VideoParameters _selectedVideoParameters = VideoParametersPresets.h720_169; + + @override + void initState() { + super.initState(); + _subscription = + Hardware.instance.onDeviceChange.stream.listen(_loadDevices); + Hardware.instance.enumerateDevices().then(_loadDevices); + } + + @override + void deactivate() { + _subscription?.cancel(); + super.deactivate(); + } + + void _loadDevices(List devices) async { + _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); + _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); + + if (_audioInputs.isNotEmpty) { + if (_selectedAudioDevice == null) { + _selectedAudioDevice = _audioInputs.first; + Future.delayed(const Duration(milliseconds: 100), () async { + await _changeLocalAudioTrack(); + setState(() {}); + }); + } + } + + if (_videoInputs.isNotEmpty) { + if (_selectedVideoDevice == null) { + _selectedVideoDevice = _videoInputs.first; + Future.delayed(const Duration(milliseconds: 100), () async { + await _changeLocalVideoTrack(); + setState(() {}); + }); + } + } + setState(() {}); + } + + Future _setEnableVideo(value) async { + _enableVideo = value; + if (!_enableVideo) { + await _videoTrack?.stop(); + _videoTrack = null; + } else { + await _changeLocalVideoTrack(); + } + setState(() {}); + } + + Future _setEnableAudio(value) async { + _enableAudio = value; + if (!_enableAudio) { + await _audioTrack?.stop(); + _audioTrack = null; + } else { + await _changeLocalAudioTrack(); + } + setState(() {}); + } + + Future _changeLocalAudioTrack() async { + if (_audioTrack != null) { + await _audioTrack!.stop(); + _audioTrack = null; + } + + if (_selectedAudioDevice != null) { + _audioTrack = await LocalAudioTrack.create(AudioCaptureOptions( + deviceId: _selectedAudioDevice!.deviceId, + )); + await _audioTrack!.start(); + } + } + + Future _changeLocalVideoTrack() async { + if (_videoTrack != null) { + await _videoTrack!.stop(); + _videoTrack = null; + } + + if (_selectedVideoDevice != null) { + _videoTrack = + await LocalVideoTrack.createCameraTrack(CameraCaptureOptions( + deviceId: _selectedVideoDevice!.deviceId, + params: _selectedVideoParameters, + )); + await _videoTrack!.start(); + } + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + _join(BuildContext context) async { + _busy = true; + + setState(() {}); + + var args = widget.args; + + try { + //create new room + final room = Room(); + + // Create a Listener before connecting + final listener = room.createListener(); + + E2EEOptions? e2eeOptions; + if (args.e2ee && args.e2eeKey != null) { + final keyProvider = await BaseKeyProvider.create(); + e2eeOptions = E2EEOptions(keyProvider: keyProvider); + await keyProvider.setKey(args.e2eeKey!); + } + + // Try to connect to the room + // This will throw an Exception if it fails for any reason. + await room.connect( + args.url, + args.token, + roomOptions: RoomOptions( + adaptiveStream: args.adaptiveStream, + dynacast: args.dynacast, + defaultAudioPublishOptions: + const AudioPublishOptions(name: 'custom_audio_track_name'), + defaultVideoPublishOptions: VideoPublishOptions( + simulcast: args.simulcast, + videoCodec: args.preferredCodec, + backupVideoCodec: BackupVideoCodec( + enabled: args.enableBackupVideoCodec, + ), + ), + defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( + useiOSBroadcastExtension: true, + params: VideoParameters( + dimensions: VideoDimensionsPresets.h1080_169, + encoding: VideoEncoding( + maxBitrate: 3 * 1000 * 1000, + maxFramerate: 15, + ))), + defaultCameraCaptureOptions: CameraCaptureOptions( + maxFrameRate: 30, params: _selectedVideoParameters), + e2eeOptions: e2eeOptions, + ), + fastConnectOptions: FastConnectOptions( + microphone: TrackOption(track: _audioTrack), + camera: TrackOption(track: _videoTrack), + ), + ); + + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => RoomPage(room, listener)), + ); + } catch (error) { + print('Could not connect $error'); + await context.showErrorDialog(error); + } finally { + setState(() { + _busy = false; + }); + } + } + + void _actionBack(BuildContext context) async { + await _setEnableVideo(false); + await _setEnableAudio(false); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'Select Devices', + style: TextStyle( + color: Colors.white, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => _actionBack(context), + ), + ), + body: Container( + alignment: Alignment.center, + child: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 20, + ), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: SizedBox( + width: 320, + height: 240, + child: Container( + alignment: Alignment.center, + color: Colors.black54, + child: _videoTrack != null + ? VideoTrackRenderer( + _videoTrack!, + fit: RTCVideoViewObjectFit + .RTCVideoViewObjectFitContain, + ) + : Container( + alignment: Alignment.center, + child: LayoutBuilder( + builder: (ctx, constraints) => Icon( + Icons.videocam_off, + color: LKColors.lkBlue, + size: math.min(constraints.maxHeight, + constraints.maxWidth) * + 0.3, + ), + ), + ), + ))), + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Camera:'), + Switch( + value: _enableVideo, + onChanged: (value) => _setEnableVideo(value), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 25), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + disabledHint: const Text('Disable Camera'), + hint: const Text( + 'Select Camera', + ), + items: _enableVideo + ? _videoInputs + .map((MediaDevice item) => + DropdownMenuItem( + value: item, + child: Text( + item.label, + style: const TextStyle( + fontSize: 14, + ), + ), + )) + .toList() + : [], + value: _selectedVideoDevice, + onChanged: (MediaDevice? value) async { + if (value != null) { + _selectedVideoDevice = value; + await _changeLocalVideoTrack(); + setState(() {}); + } + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.symmetric(horizontal: 16), + height: 40, + width: 140, + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + ), + ), + ), + if (_enableVideo) + Padding( + padding: const EdgeInsets.only(bottom: 25), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: const Text( + 'Select Video Dimensions', + ), + items: [ + VideoParametersPresets.h480_43, + VideoParametersPresets.h540_169, + VideoParametersPresets.h720_169, + VideoParametersPresets.h1080_169, + ] + .map((VideoParameters item) => + DropdownMenuItem( + value: item, + child: Text( + '${item.dimensions.width}x${item.dimensions.height}', + style: const TextStyle( + fontSize: 14, + ), + ), + )) + .toList(), + value: _selectedVideoParameters, + onChanged: (VideoParameters? value) async { + if (value != null) { + _selectedVideoParameters = value; + await _changeLocalVideoTrack(); + setState(() {}); + } + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.symmetric(horizontal: 16), + height: 40, + width: 140, + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Micriphone:'), + Switch( + value: _enableAudio, + onChanged: (value) => _setEnableAudio(value), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 25), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + disabledHint: const Text('Disable Microphone'), + hint: const Text( + 'Select Micriphone', + ), + items: _enableAudio + ? _audioInputs + .map((MediaDevice item) => + DropdownMenuItem( + value: item, + child: Text( + item.label, + style: const TextStyle( + fontSize: 14, + ), + ), + )) + .toList() + : [], + value: _selectedAudioDevice, + onChanged: (MediaDevice? value) async { + if (value != null) { + _selectedAudioDevice = value; + await _changeLocalAudioTrack(); + setState(() {}); + } + }, + buttonStyleData: const ButtonStyleData( + padding: EdgeInsets.symmetric(horizontal: 16), + height: 40, + width: 140, + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + ), + ), + ), + ElevatedButton( + onPressed: _busy ? null : () => _join(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_busy) + const Padding( + padding: EdgeInsets.only(right: 10), + child: SizedBox( + height: 15, + width: 15, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + const Text('JOIN'), + ], + ), + ), + ]), + )))); + } +} diff --git a/example/lib/pages/room.dart b/example/lib/pages/room.dart index ef8341eb..3e8d5716 100644 --- a/example/lib/pages/room.dart +++ b/example/lib/pages/room.dart @@ -13,7 +13,6 @@ import '../widgets/participant.dart'; import '../widgets/participant_info.dart'; class RoomPage extends StatefulWidget { - // final Room room; final EventsListener listener; @@ -80,8 +79,8 @@ class _RoomPageState extends State { if (event.reason != null) { print('Room disconnected: reason => ${event.reason}'); } - WidgetsBindingCompatible.instance - ?.addPostFrameCallback((timeStamp) => Navigator.pop(context)); + WidgetsBindingCompatible.instance?.addPostFrameCallback( + (timeStamp) => Navigator.popUntil(context, (route) => route.isFirst)); }) ..on((event) { print('Participant event'); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 11281b96..92065730 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -19,10 +19,11 @@ dependencies: shared_preferences: ^2.0.7 google_fonts: ^4.0.4 flutter_svg: ^2.0.5 + dropdown_button2: ^2.3.6 flutter_window_close: ^0.2.2 + livekit_client: path: ../ - # git: # url: https://github.com/livekit/client-sdk-flutter # ref: main diff --git a/lib/src/track/local/local.dart b/lib/src/track/local/local.dart index 00fbea0a..9668766a 100644 --- a/lib/src/track/local/local.dart +++ b/lib/src/track/local/local.dart @@ -70,6 +70,8 @@ abstract class LocalTrack extends Track { String? codec; + bool _stopped = false; + LocalTrack( lk_models.TrackType kind, TrackSource source, @@ -117,7 +119,7 @@ abstract class LocalTrack extends Track { @override Future stop() async { - final didStop = await super.stop(); + final didStop = await super.stop() || !_stopped; if (didStop) { logger.fine('Stopping mediaStreamTrack...'); try { @@ -130,6 +132,7 @@ abstract class LocalTrack extends Track { } catch (error) { logger.severe('MediaStreamTrack.dispose() did throw $error'); } + _stopped = true; } return didStop; } diff --git a/lib/src/widgets/video_track_renderer.dart b/lib/src/widgets/video_track_renderer.dart index 4addf053..45fb9907 100644 --- a/lib/src/widgets/video_track_renderer.dart +++ b/lib/src/widgets/video_track_renderer.dart @@ -56,13 +56,6 @@ class _VideoTrackRendererState extends State { // Used to compute visibility information late GlobalKey _internalKey; - Future _initializeRenderer() async { - _renderer ??= rtc.RTCVideoRenderer(); - await _renderer!.initialize(); - await _attach(); - return _renderer; - } - void disposeRenderer() { try { _renderer?.srcObject = null; @@ -77,6 +70,12 @@ class _VideoTrackRendererState extends State { void initState() { super.initState(); _internalKey = widget.track.addViewKey(); + () async { + _renderer ??= rtc.RTCVideoRenderer(); + await _renderer!.initialize(); + await _attach(); + setState(() {}); + }(); } @override @@ -120,30 +119,24 @@ class _VideoTrackRendererState extends State { } @override - Widget build(BuildContext context) => FutureBuilder( - future: _initializeRenderer(), - builder: (context, snapshot) { - if (snapshot.hasData && _renderer != null) { - return 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, - ); - }, - ); - } - - return Container(); - }); + Widget build(BuildContext context) => _renderer != null + ? 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, + ); + }, + ) + : Container(); bool _shouldMirror() { // off for screen share