diff --git a/.gitignore b/.gitignore index dbef116..8ddf60c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ doc/api/ *.js_ *.js.deps *.js.map + +.DS_Store +.flutter-plugins diff --git a/example/lib/main.dart b/example/lib/main.dart index 9b80a8b..2b7af20 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -5,8 +5,6 @@ import 'package:logging/logging.dart'; import 'package:intl/intl.dart'; import 'package:responsive_builder/responsive_builder.dart'; -import 'src/utils.dart'; - void main() { final format = DateFormat('HH:mm:ss'); // configure logs for debugging @@ -48,9 +46,6 @@ class MyHomePage extends StatelessWidget { print('Joining room: name=$name, roomName=$roomName'); } try { - final details = await fetchConnectionDetails(name, roomName); - await roomCtx.connect( - url: details.serverUrl, token: details.participantToken); await roomCtx.connect( url: url, token: token, @@ -65,7 +60,11 @@ class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { return LivekitRoom( - roomContext: RoomContext(), + roomContext: RoomContext( + url: url, + token: token, + enableAudioVisulizer: true, + ), builder: (context, roomCtx) { var deviceScreenType = getDeviceType(MediaQuery.of(context).size); return Scaffold( @@ -85,7 +84,7 @@ class MyHomePage extends StatelessWidget { ? Prejoin( token: token, url: url, - //onJoinPressed: _onJoinPressed, + onJoinPressed: _onJoinPressed, ) : @@ -116,8 +115,9 @@ class MyHomePage extends StatelessWidget { children: [ /// show participant loop ParticipantLoop( - showAudioTracks: false, + showAudioTracks: true, showVideoTracks: true, + showParticipantPlaceholder: true, /// layout builder layoutBuilder: @@ -126,28 +126,32 @@ class MyHomePage extends StatelessWidget { : const GridLayoutBuilder(), /// participant builder - participantBuilder: (context) { + participantTrackBuilder: + (context, identifier) { // build participant widget for each Track return Padding( padding: const EdgeInsets.all(2.0), child: Stack( children: [ /// video track widget in the background - - IsSpeakingIndicator( - builder: (context, isSpeaking) { - return isSpeaking != null - ? IsSpeakingIndicatorWidget( - isSpeaking: - isSpeaking, - child: - const VideoTrackWidget(), - ) - : const VideoTrackWidget(); - }, - ), - - /// TODO: Add AudioTrackWidget or AgentVisualizerWidget later + identifier.isAudio && + roomCtx + .enableAudioVisulizer + ? const AudioVisualizerWidget() + : IsSpeakingIndicator( + builder: (context, + isSpeaking) { + return isSpeaking != + null + ? IsSpeakingIndicatorWidget( + isSpeaking: + isSpeaking, + child: + const VideoTrackWidget(), + ) + : const VideoTrackWidget(); + }, + ), /// focus toggle button at the top right const Positioned( @@ -164,7 +168,12 @@ class MyHomePage extends StatelessWidget { ), /// status bar at the bottom - const ParticipantStatusBar(), + const Positioned( + bottom: 0, + left: 0, + right: 0, + child: ParticipantStatusBar(), + ), ], ), ); diff --git a/example/lib/src/utils.dart b/example/lib/src/utils.dart deleted file mode 100644 index 863f874..0000000 --- a/example/lib/src/utils.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:http/http.dart' as http; -import 'dart:convert'; - -class ConnectionDetails { - final String serverUrl; - final String roomName; - final String participantToken; - final String participantName; - - ConnectionDetails({ - required this.serverUrl, - required this.roomName, - required this.participantToken, - required this.participantName, - }); - - factory ConnectionDetails.fromJson(Map json) { - return ConnectionDetails( - serverUrl: json['serverUrl'], - roomName: json['roomName'], - participantToken: json['participantToken'], - participantName: json['participantName'], - ); - } -} - -Future fetchConnectionDetails( - String name, String roomName) async { - final response = await http.get(Uri.parse( - 'https://meet.staging.livekit.io/api/connection-details?roomName=$roomName&participantName=$name')); - if (response.statusCode == 200) { - return ConnectionDetails.fromJson( - const JsonDecoder().convert(response.body)); - } else { - throw Exception('Failed to load connection details'); - } -} diff --git a/lib/livekit_components.dart b/lib/livekit_components.dart index 848ab30..f7e9069 100644 --- a/lib/livekit_components.dart +++ b/lib/livekit_components.dart @@ -65,6 +65,7 @@ export 'src/ui/widgets/room/disconnect_button.dart'; export 'src/ui/widgets/room/screenshare_toggle.dart'; export 'src/ui/widgets/room/clear_pin_button.dart'; +export 'src/ui/widgets/track/audio_visualizer_widget.dart'; export 'src/ui/widgets/track/focus_toggle.dart'; export 'src/ui/widgets/track/no_track_widget.dart'; export 'src/ui/widgets/track/video_track_widget.dart'; diff --git a/lib/src/context/media_device_context.dart b/lib/src/context/media_device_context.dart index 790bb3d..c54b381 100644 --- a/lib/src/context/media_device_context.dart +++ b/lib/src/context/media_device_context.dart @@ -96,6 +96,7 @@ class MediaDeviceContext extends ChangeNotifier { AudioCaptureOptions( deviceId: selectedAudioInputDeviceId, ), + _roomCtx.enableAudioVisulizer, ); } selectedAudioInputDeviceId = device.deviceId; @@ -137,6 +138,7 @@ class MediaDeviceContext extends ChangeNotifier { AudioCaptureOptions( deviceId: selectedAudioInputDeviceId, ), + _roomCtx.enableAudioVisulizer, ); } notifyListeners(); diff --git a/lib/src/context/room_context.dart b/lib/src/context/room_context.dart index 9320183..47cb730 100644 --- a/lib/src/context/room_context.dart +++ b/lib/src/context/room_context.dart @@ -49,6 +49,7 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { bool connect = false, RoomOptions roomOptions = const RoomOptions(), ConnectOptions? connectOptions, + this.enableAudioVisulizer = false, this.onConnected, this.onDisconnected, this.onError, @@ -198,6 +199,12 @@ class RoomContext extends ChangeNotifier with ChatContextMixin { /// Get the [Room] instance. Room get room => _room; + /// enable audio visualizer, default is false + /// if true, the audio visualizer will be enabled in the room. + /// you can use the [AudioVisualizerWidget] widget to show the + /// audio visualizer. + final bool enableAudioVisulizer; + String? _roomName; /// Get the room name. diff --git a/lib/src/context/track_reference_context.dart b/lib/src/context/track_reference_context.dart index 7cea995..87e9af8 100644 --- a/lib/src/context/track_reference_context.dart +++ b/lib/src/context/track_reference_context.dart @@ -44,6 +44,20 @@ class TrackReferenceContext extends ChangeNotifier { notifyListeners(); } }) + ..on((event) { + if (event.publication.sid == pub?.sid) { + Debug.event( + 'TrackContext: LocalTrackPublishedEvent for ${_participant.sid}'); + notifyListeners(); + } + }) + ..on((event) { + if (event.publication.sid == pub?.sid) { + Debug.event( + 'TrackContext: TrackSubscribedEvent for ${_participant.sid}'); + notifyListeners(); + } + }) ..on((event) { if (event.publication.sid == pub?.sid) { Debug.event( @@ -56,6 +70,7 @@ class TrackReferenceContext extends ChangeNotifier { @override void dispose() { super.dispose(); + _listener.cancelAll(); _listener.dispose(); if (_statsListener != null) { _statsListener!.dispose(); diff --git a/lib/src/types/track_identifier.dart b/lib/src/types/track_identifier.dart index 864e5b7..7ec8988 100644 --- a/lib/src/types/track_identifier.dart +++ b/lib/src/types/track_identifier.dart @@ -20,4 +20,14 @@ class TrackIdentifier { final TrackPublication? track; String? get identifier => track?.sid ?? participant.sid; + + TrackSource get source => track?.source ?? TrackSource.unknown; + + bool get isAudio => + source == TrackSource.microphone || + source == TrackSource.screenShareAudio; + bool get isVideo => + source == TrackSource.camera || source == TrackSource.camera; + + bool get hasTrack => track != null; } diff --git a/lib/src/ui/builder/participant/participant_loop.dart b/lib/src/ui/builder/participant/participant_loop.dart index 114303f..ac9cc7b 100644 --- a/lib/src/ui/builder/participant/participant_loop.dart +++ b/lib/src/ui/builder/participant/participant_loop.dart @@ -25,22 +25,27 @@ import '../../layout/layouts.dart'; import '../../layout/sorting.dart'; import 'participant_track.dart'; +typedef PaticipantTrackBuilder = Widget Function( + BuildContext context, TrackIdentifier identifier); + class ParticipantLoop extends StatelessWidget { const ParticipantLoop({ super.key, - required this.participantBuilder, + required this.participantTrackBuilder, this.layoutBuilder = const GridLayoutBuilder(), this.sorting = defaultSorting, this.showAudioTracks = false, this.showVideoTracks = true, + this.showParticipantPlaceholder = true, }); - final WidgetBuilder participantBuilder; + final PaticipantTrackBuilder participantTrackBuilder; final List Function(List tracks)? sorting; final ParticipantLayoutBuilder layoutBuilder; final bool showAudioTracks; final bool showVideoTracks; + final bool showParticipantPlaceholder; List> buildTracksMap( bool audio, bool video, List participants) { @@ -93,24 +98,26 @@ class ParticipantLoop extends StatelessWidget { for (var item in trackMap) { var identifier = item.key; var track = item.value; - if (track == null) { + if (track != null) { trackWidgets.add( TrackWidget( identifier, ParticipantTrack( participant: identifier.participant, - builder: (context) => participantBuilder(context), + track: track, + builder: (context) => + participantTrackBuilder(context, identifier), ), ), ); - } else { + } else if (showParticipantPlaceholder) { trackWidgets.add( TrackWidget( identifier, ParticipantTrack( participant: identifier.participant, - track: track, - builder: (context) => participantBuilder(context), + builder: (context) => + participantTrackBuilder(context, identifier), ), ), ); diff --git a/lib/src/ui/widgets/track/audio_visualizer_widget.dart b/lib/src/ui/widgets/track/audio_visualizer_widget.dart new file mode 100644 index 0000000..64b94c1 --- /dev/null +++ b/lib/src/ui/widgets/track/audio_visualizer_widget.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import 'package:livekit_client/livekit_client.dart'; +import 'package:provider/provider.dart'; + +import '../../../context/track_reference_context.dart'; +import '../theme.dart'; +import 'no_track_widget.dart'; + +class AudioVisualizerWidget extends StatelessWidget { + const AudioVisualizerWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + var trackCtx = Provider.of(context); + + if (trackCtx == null) { + return const NoTrackWidget(); + } + + return Consumer( + builder: (context, trackCtx, child) => + Selector( + selector: (context, audioTrack) => trackCtx.audioTrack, + builder: (BuildContext context, AudioTrack? audioTrack, Widget? child) { + if (trackCtx.audioTrack == null) { + return const NoTrackWidget(); + } + return Container( + color: LKColors.lkDarkBlue, + child: Center( + child: SoundWaveformWidget( + audioTrack: audioTrack, + count: 7, + width: 12, + minHeight: 12, + maxHeight: 100, + durationInMilliseconds: 500, + ), + ), + ); + }, + ), + ); + } +} + +class SoundWaveformWidget extends StatefulWidget { + final int count; + final double width; + final double minHeight; + final double maxHeight; + final int durationInMilliseconds; + const SoundWaveformWidget({ + super.key, + this.audioTrack, + this.count = 7, + this.width = 5, + this.minHeight = 8, + this.maxHeight = 100, + this.durationInMilliseconds = 500, + }); + final AudioTrack? audioTrack; + @override + State createState() => _SoundWaveformWidgetState(); +} + +class _SoundWaveformWidgetState extends State + with TickerProviderStateMixin { + late AnimationController controller; + List samples = [0, 0, 0, 0, 0, 0, 0]; + EventsListener? _listener; + + void _startVisualizer(AudioTrack? track) async { + _listener = track?.createListener(); + _listener + ?..on((e) { + if (mounted) { + setState(() { + samples = + e.event.map((e) => ((e as num) * 100).toDouble()).toList(); + }); + } + }) + ..on((e) { + if (mounted) { + setState(() { + samples = List.filled(samples.length, 0); + }); + } + }); + } + + void _stopVisualizer() async { + await _listener?.dispose(); + } + + @override + void initState() { + super.initState(); + + controller = AnimationController( + vsync: this, + duration: Duration( + milliseconds: widget.durationInMilliseconds, + )) + ..repeat(); + + _startVisualizer(widget.audioTrack); + } + + @override + void dispose() { + controller.dispose(); + _stopVisualizer(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final count = widget.count; + final minHeight = widget.minHeight; + final maxHeight = widget.maxHeight; + return AnimatedBuilder( + animation: controller, + builder: (c, child) { + //double t = controller.value; + //int current = (samples.length * t).floor(); + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + count, + (i) => AnimatedContainer( + duration: Duration( + milliseconds: widget.durationInMilliseconds ~/ count), + margin: i == (samples.length - 1) + ? EdgeInsets.zero + : const EdgeInsets.only(right: 5), + height: samples[i] < minHeight + ? minHeight + : samples[i] > maxHeight + ? maxHeight + : samples[i], + width: widget.width, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(9999), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/ui/widgets/track/video_track_widget.dart b/lib/src/ui/widgets/track/video_track_widget.dart index 085dbbd..1987cb9 100644 --- a/lib/src/ui/widgets/track/video_track_widget.dart +++ b/lib/src/ui/widgets/track/video_track_widget.dart @@ -34,7 +34,7 @@ class VideoTrackWidget extends StatelessWidget { Debug.log('===> VideoTrackWidget for $sid'); - if (trackCtx == null) { + if (trackCtx == null || trackCtx.videoTrack == null) { return const NoTrackWidget(); } diff --git a/pubspec.yaml b/pubspec.yaml index 154afd9..ca91f2e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,11 +12,11 @@ dependencies: flutter: sdk: flutter flutter_background: ^1.3.0+1 - flutter_webrtc: ^0.12.1+hotfix.1 + flutter_webrtc: ^0.12.3 google_fonts: ^6.2.1 http: ^1.2.2 intl: ^0.19.0 - livekit_client: ^2.3.0 + livekit_client: ^2.3.2 provider: ^6.1.2 responsive_builder: ^0.7.1