From d011bc7d3d1dd850320b547b52cbc0867dc87b42 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 30 Aug 2021 15:31:12 -0700 Subject: [PATCH] Documentation --- README.md | 253 +++++++++++++++++++- lib/livekit_client.dart | 2 + lib/src/livekit.dart | 5 +- lib/src/participant/local_participant.dart | 11 +- lib/src/participant/participant.dart | 33 ++- lib/src/participant/remote_participant.dart | 5 + lib/src/room.dart | 58 ++++- lib/src/rtc_engine.dart | 1 + lib/src/signal_client.dart | 9 +- lib/src/track/audio_track.dart | 2 + lib/src/track/local_audio_track.dart | 1 + lib/src/track/local_track_publication.dart | 1 + lib/src/track/local_video_track.dart | 5 + lib/src/track/options.dart | 2 + lib/src/track/remote_track_publication.dart | 4 + lib/src/track/track.dart | 1 + lib/src/track/track_publication.dart | 3 + lib/src/track/video_track.dart | 2 + lib/src/widget/video_track_renderer.dart | 1 + 19 files changed, 383 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0283162..738ec23 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,254 @@ # LiveKit Flutter SDK -Docs coming soon. package available on pub.dev as [livekit_client](https://pub.dev/packages/livekit_client) +Official Flutter SDK for [LiveKit](https://livekit.io). Easily add real-time video and audio to your Flutter apps. + +This package is published to pub.dev as [livekit_client](https://pub.dev/packages/livekit_client). + +## Docs + +Docs and guides at [https://docs.livekit.io](https://docs.livekit.io) + +## Installation + +Include this package to your `pubspec.yaml` + +```yaml +... +dependencies: + livekit_client: +``` + +### iOS + +Camera and microphone usage need to be declared in your `Info.plist` file. + +```xml +... + + NSCameraUsageDescription + $(PRODUCT_NAME) uses your camera + NSMicrophoneUsageDescription + $(PRODUCT_NAME) uses your microphone + +``` + +### Android + +We require a set of permissions that need to be declared in your `AppManifest.xml`. These are required by Flutter WebRTC, which we depend on. + +```xml + + + + + + + + + ... + +``` + +## Example app + +We built a multi-user conferencing app as an example in the [example/](example/) folder. You can join the same room from any supported LiveKit clients. + +## Usage + +### Connecting to a room, publish video & audio + +```dart +var room = await LiveKitClient.connect(this.url, this.token); +try { + // video will fail when running in ios simulator + var localVideo = await LocalVideoTrack.createCameraTrack(); + await room.localParticipant.publishVideoTrack(localVideo); +} catch (e) { + print('could not publish video: $e'); +} + +var localAudio = await LocalAudioTrack.createTrack(); +await room.localParticipant.publishAudioTrack(localAudio); +``` + +### Rendering video + +Each track can be rendered separately with the provided `VideoTrackRenderer` widget. + +```dart +VideoTrack? track; + +@override +Widget build(BuildContext context) { + if (track != null) { + return VideoTrackRenderer(track); + } else { + return Container( + color: Colors.grey, + ); + } +} +``` + +### Audio handling + +Audio tracks are rendered automatically as long as you are subscribed to them. + +### Handling changes + +LiveKit client makes it simple to build declarative UI that reacts to state changes. It notifies changes in two ways + +* `ChangeNotifier` - generic notification of changes +* `RoomDelegate` and `ParticipantDelegate` - notification of specific events. + +This example will show you how to use both to react to room events. + +```dart +class RoomWidget extends StatefulWidget { + final Room room; + + RoomWidget(this.room); + + @override + State createState() { + return _RoomState(); + } +} + +class _RoomState extends State with RoomDelegate { + @override + void initState() { + super.initState(); + widget.room.delegate = this; + widget.room.addListener(_onChange); + } + + @override + void dispose() { + widget.room.delegate = null; + super.dispose(); + } + + void _onChange() { + // perform computations and then call setState + // setState will trigger a build + setState(() { + // your updates here + }); + } + + @override + void onDisconnected() { + // onDisconnected is a RoomDelegate method, handle when disconnected from room + } + + @override + Widget build(BuildContext context) { + // your build function + } +} +``` + +Similarly, you could do the same when rendering participants. Reacting to changes makes it possible to handle tracks published/unpublished or re-ordering participants in your UI. + +```dart +class VideoView extends StatefulWidget { + final Participant participant; + + VideoView(this.participant); + + @override + State createState() { + return _VideoViewState(); + } +} + +class _VideoViewState extends State with ParticipantDelegate { + TrackPublication? videoPub; + + @override + void initState() { + super.initState(); + widget.participant.addListener(this._onParticipantChanged); + // trigger initial change + _onParticipantChanged(); + } + + @override + void dispose() { + widget.participant.removeListener(this._onParticipantChanged); + super.dispose(); + } + + @override + void didUpdateWidget(covariant VideoView oldWidget) { + oldWidget.participant.removeListener(_onParticipantChanged); + widget.participant.addListener(_onParticipantChanged); + _onParticipantChanged(); + super.didUpdateWidget(oldWidget); + } + + void _onParticipantChanged() { + var subscribedVideos = widget.participant.videoTracks.values.where((pub) { + return pub.kind == TrackType.VIDEO && + !pub.isScreenShare && + pub.subscribed; + }); + + setState(() { + if (subscribedVideos.length > 0) { + var videoPub = subscribedVideos.first; + if (videoPub is RemoteTrackPublication) { + videoPub.videoQuality = widget.quality; + } + // when muted, show placeholder + if (!videoPub.muted) { + this.videoPub = videoPub; + return; + } + } + this.videoPub = null; + }); + } + + @override + Widget build(BuildContext context) { + var videoPub = this.videoPub; + if (videoPub != null) { + return VideoTrackRenderer(videoPub.track as VideoTrack); + } else { + return Container( + color: Colors.grey, + ); + } + } +} +``` + +### Mute, unmute local tracks + +On `LocalTrackPublication`s, you could control if the track is muted by setting its `muted` property. Changing the mute status will generate an `onTrackMuted` or `onTrack Unmuted` delegate call for the local participant. Other participant will receive the status change as well. + +```dart +// mute track +trackPub.muted = true; + +// unmute track +trackPub.muted = false; +``` + +### Subscriber controls + +When subscribing to remote tracks, the client has precise control over status of its subscriptions. You could subscribe or unsubscribe to a track, change its quality, or disabling the track temporarily. + +These controls are accessible on the `RemoteTrackPublication` object. + +For more info, see [Subscriber controls](https://docs.livekit.io/guides/room/receive#subscriber-controls). + +## License + +Apache License 2.0 + +## Thanks + +A huge thank you to [flutter-webrtc](https://github.com/flutter-webrtc/flutter-webrtc) for making it possible to use WebRTC in Flutter. diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index aaeefdb..c679729 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -1,8 +1,10 @@ +/// Flutter Client SDK to LiveKit. library livekit_client; export 'src/livekit.dart'; export 'src/errors.dart'; export 'src/room.dart'; +export 'src/options.dart'; export 'src/participant/participant.dart'; export 'src/participant/local_participant.dart'; export 'src/participant/remote_participant.dart'; diff --git a/lib/src/livekit.dart b/lib/src/livekit.dart index 40d46b2..b913b67 100644 --- a/lib/src/livekit.dart +++ b/lib/src/livekit.dart @@ -1,7 +1,10 @@ import 'room.dart'; -import 'signal_client.dart'; +import 'options.dart'; +/// Main entry point to connect to a room. +/// {@category Room} class LiveKitClient { + /// Connects to a LiveKit room static Future connect(String url, String token, [JoinOptions? options]) { var room = Room(); diff --git a/lib/src/participant/local_participant.dart b/lib/src/participant/local_participant.dart index c24951a..1e5e960 100644 --- a/lib/src/participant/local_participant.dart +++ b/lib/src/participant/local_participant.dart @@ -11,6 +11,7 @@ import '../track/track.dart'; import '../track/track_publication.dart'; import 'participant.dart'; +/// Represents the current participant in the room. class LocalParticipant extends Participant { RTCEngine _engine; @@ -22,6 +23,8 @@ class LocalParticipant extends Participant { updateFromInfo(info); } + /// for internal use + /// {@nodoc} RTCEngine get engine => _engine; /// publish an audio track to the room @@ -53,7 +56,7 @@ class LocalParticipant extends Participant { } } - /// publish a video track to the room + /// Publish a video track to the room Future publishVideoTrack(LocalVideoTrack track) async { if (videoTracks.values.any( (element) => element.track?.mediaTrack.id == track.mediaTrack.id)) { @@ -83,6 +86,7 @@ class LocalParticipant extends Participant { } } + /// Unpublish a track that's already published unpublishTrack(Track track) { var existing = tracks.values.where((element) => element.track == track); if (existing.isEmpty) { @@ -107,6 +111,8 @@ class LocalParticipant extends Participant { } } + /// Publish a new data payload to the room. + /// @param destinationSids When empty, data will be forwarded to each participant in the room. publishData(List data, DataPacket_Kind reliability, {List? destinationSids}) { RTCDataChannel? channel; @@ -135,6 +141,9 @@ class LocalParticipant extends Participant { channel.send(RTCDataChannelMessage.fromBinary(buffer)); } + /// for internal use + /// {@nodoc} + @override updateFromInfo(ParticipantInfo info) { super.updateFromInfo(info); } diff --git a/lib/src/participant/participant.dart b/lib/src/participant/participant.dart index 023fc0f..7aab9a0 100644 --- a/lib/src/participant/participant.dart +++ b/lib/src/participant/participant.dart @@ -6,20 +6,42 @@ import '../track/remote_track_publication.dart'; import '../track/track.dart'; import '../track/track_publication.dart'; +/// Callbacks for participant changes mixin ParticipantDelegate { + /// The participant's metadata has changed void onMetadataChanged(Participant participant) {} + + /// The participant's isSpeaking property has changed void onSpeakingChanged(Participant participant, bool speaking) {} + + /// This participant has muted one of their tracks void onTrackMuted(Participant participant, TrackPublication publication) {} + + /// This participant has unmuted one of their tracks void onTrackUnmuted(Participant participant, TrackPublication publication) {} + + /// This participant has published a new [Track] to the [Room]. void onTrackPublished( RemoteParticipant participant, RemoteTrackPublication publication) {} + + /// This participant has unpublished one of their [Track]. void onTrackUnpublished( RemoteParticipant participant, RemoteTrackPublication publication) {} + + /// The [LocalParticipant] has subscribed to a new track published by this + /// [RemoteParticipant] void onTrackSubscribed(RemoteParticipant participant, Track track, RemoteTrackPublication publication) {} + + /// The [LocalParticipant] has unsubscribed from a track published by this + /// [RemoteParticipant]. This event is fired when the track was unpublished void onTrackUnsubscribed(RemoteParticipant participant, Track track, RemoteTrackPublication publication) {} + + /// Data received from this [RemoteParticipant]. void onDataReceived(RemoteParticipant participant, List data) {} + + /// An error has occured during track subscription. void onTrackSubscriptionFailed( RemoteParticipant participant, String sid, String? message) {} } @@ -97,11 +119,14 @@ class Participant extends ChangeNotifier { return result; } - /// internal use + /// for internal use + /// {@nodoc} bool get hasInfo => _participantInfo != null; Participant(this.sid, this.identity); + /// for internal use + /// {@nodoc} set isSpeaking(bool speaking) { if (_isSpeaking == speaking) { return; @@ -125,6 +150,8 @@ class Participant extends ChangeNotifier { } } + /// for internal use + /// {@nodoc} updateFromInfo(ParticipantInfo info) { this.identity = info.identity; this.sid = info.sid; @@ -134,10 +161,14 @@ class Participant extends ChangeNotifier { this._participantInfo = info; } + /// for internal use + /// {@nodoc} muteChanged() { notifyListeners(); } + /// for internal use + /// {@nodoc} addTrackPublication(TrackPublication pub) { pub.track?.sid = pub.sid; tracks[pub.sid] = pub; diff --git a/lib/src/participant/remote_participant.dart b/lib/src/participant/remote_participant.dart index 1e44a93..c39da3e 100644 --- a/lib/src/participant/remote_participant.dart +++ b/lib/src/participant/remote_participant.dart @@ -7,6 +7,7 @@ import '../track/track.dart'; import '../track/video_track.dart'; import 'participant.dart'; +/// Represents other participant in the [Room]. class RemoteParticipant extends Participant { SignalClient _client; @@ -27,6 +28,8 @@ class RemoteParticipant extends Participant { } } + /// for internal use + /// {@nodoc} addSubscribedMediaTrack( MediaStreamTrack mediaTrack, MediaStream stream, String? sid) async { if (sid == null) { @@ -70,6 +73,8 @@ class RemoteParticipant extends Participant { notifyListeners(); } + /// for internal use + /// {@nodoc} @override void updateFromInfo(ParticipantInfo info) { var hadInfo = hasInfo; diff --git a/lib/src/room.dart b/lib/src/room.dart index b737f62..2493c17 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -8,6 +8,7 @@ import 'package:tuple/tuple.dart'; import 'errors.dart'; import 'extensions.dart'; import 'logger.dart'; +import 'options.dart'; import 'participant/local_participant.dart'; import 'participant/participant.dart'; import 'participant/remote_participant.dart'; @@ -25,39 +26,83 @@ enum RoomState { Reconnecting, } +/// Delegate for [Room] callbacks mixin RoomDelegate { // room level callbacks + /// When the connection to the server has been interrupted and it's attempting + /// to reconnect. void onReconnecting() {} + + /// Connection to room is re-established. All existing state is preserved. void onReconnected() {} + + /// Disconnected from the room void onDisconnected() {} + + /// When a new [RemoteParticipant] joins *after* the current participant has connected + /// It will not fire for participants that are already in the room void onParticipantConnected(Participant participant) {} + + /// When a [RemoteParticipant] leaves the room void onParticipantDisconnected(Participant participant) {} + + /// Active speakers changed. List of speakers are ordered by their audio level. + /// loudest speakers first. This will include the [LocalParticipant] too. void onActiveSpeakersChanged(List participants) {} // callbacks about participant events + + /// Participant metadata is a simple way for app-specific state to be pushed to + /// all users. + /// When RoomService.UpdateParticipantMetadata is called to change a + /// participant's state, *all* participants in the room will fire this event. void onMetadataChanged(Participant participant) {} + + /// A track that was muted, fires on both [RemoteParticipant]s and + /// [LocalParticipant] void onTrackMuted(Participant participant, TrackPublication publication) {} + + /// A track that was unmuted, fires on both [RemoteParticipant]s and + /// [LocalParticipant] void onTrackUnmuted(Participant participant, TrackPublication publication) {} + + /// When a new track is published to room *after* the current participant has + /// joined. It will not fire for tracks that are already published void onTrackPublished( RemoteParticipant participant, RemoteTrackPublication publication) {} + + /// A [RemoteParticipant] has unpublished a track void onTrackUnpublished( RemoteParticipant participant, RemoteTrackPublication publication) {} + + /// The [LocalParticipant] has subscribed to a new track. This event will **always** + /// fire as long as new tracks are ready for use. void onTrackSubscribed(RemoteParticipant participant, Track track, RemoteTrackPublication publication) {} + + /// A subscribed track is no longer available. void onTrackUnsubscribed(RemoteParticipant participant, Track track, RemoteTrackPublication publication) {} + + /// Data received from another [RemoteParticipant]. + /// Data packets provides the ability to use LiveKit to send/receive arbitrary + /// payloads. void onDataReceived(RemoteParticipant participant, List data) {} + + /// Encountered failure attempting to subscribe to track. void onTrackSubscriptionFailed( RemoteParticipant participant, String sid, String? message) {} } -/// Room is the main entrypoint to working with LiveKit. It provides -/// updates to its state via two ways, by assigning a delegate, or using +/// Room is the primary construct for LiveKit conferences. It contains a +/// group of [Participant]s, each publishing and subscribing to [Track]s. +/// Notifies changes to its state via two ways, by assigning a delegate, or using /// it as a provider. -/// Room will trigger a change update when +/// Room will trigger a change notification update when /// * state changes /// * participant membership changes /// * active speakers are different +/// {@category Room} class Room extends ChangeNotifier with ParticipantDelegate { RoomState _state = RoomState.Disconnected; @@ -92,6 +137,8 @@ class Room extends ChangeNotifier with ParticipantDelegate { Completer? _connectCompleter; + /// internal use + /// {@nodoc} Room([RTCConfiguration? rtcConfig]) : _engine = new RTCEngine(SignalClient(), rtcConfig) { _engine.onTrack = _onTrackAdded; @@ -148,6 +195,7 @@ class Room extends ChangeNotifier with ParticipantDelegate { return completer.future; } + /// Disconnects from the room, notifying server of disconnection. disconnect() { _engine.client.sendLeave(); _handleDisconnect(); @@ -288,7 +336,7 @@ class Room extends ChangeNotifier with ParticipantDelegate { return; } - var parsed = unpackStreamId(stream.id); + var parsed = _unpackStreamId(stream.id); var trackSid = parsed.item2; if (trackSid == null) { trackSid = track.id; @@ -354,7 +402,7 @@ class Room extends ChangeNotifier with ParticipantDelegate { } } -Tuple2 unpackStreamId(String streamId) { +Tuple2 _unpackStreamId(String streamId) { var parts = streamId.split('|'); if (parts.length != 2) { return Tuple2(parts[0], null); diff --git a/lib/src/rtc_engine.dart b/lib/src/rtc_engine.dart index cf39867..11ba0b8 100644 --- a/lib/src/rtc_engine.dart +++ b/lib/src/rtc_engine.dart @@ -4,6 +4,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'errors.dart'; import 'extensions.dart'; import 'logger.dart'; +import 'options.dart'; import 'proto/livekit_rtc.pb.dart'; import 'proto/livekit_models.pb.dart'; import 'signal_client.dart'; diff --git a/lib/src/signal_client.dart b/lib/src/signal_client.dart index 89f383d..fbd9ecd 100644 --- a/lib/src/signal_client.dart +++ b/lib/src/signal_client.dart @@ -3,11 +3,12 @@ import 'dart:convert'; import 'dart:developer'; import 'package:flutter_webrtc/flutter_webrtc.dart'; -import 'package:livekit_client/livekit_client.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:http/http.dart' as http; +import 'errors.dart'; import 'logger.dart'; +import 'options.dart'; import 'track/track.dart'; import 'version.dart'; import 'proto/livekit_models.pb.dart'; @@ -16,12 +17,6 @@ import '_websocket_api.dart' if (dart.library.io) '_websocket_io.dart' if (dart.library.html) '_websocket_html.dart' as platform; -class JoinOptions { - final bool? autoSubscribe; - - const JoinOptions({this.autoSubscribe}); -} - mixin SignalClientDelegate { // initial connection established void onConnected(JoinResponse response); diff --git a/lib/src/track/audio_track.dart b/lib/src/track/audio_track.dart index 80490c1..4f4f837 100644 --- a/lib/src/track/audio_track.dart +++ b/lib/src/track/audio_track.dart @@ -11,6 +11,8 @@ class AudioTrack extends Track { AudioTrack(String name, MediaStreamTrack track, this.mediaStream) : super(TrackType.AUDIO, name, track); + /// Start playing audio track. On web platform, create an audio element and + /// start playback start() { if (!(this is LocalAudioTrack)) { audio.startAudio(getCid(), mediaTrack); diff --git a/lib/src/track/local_audio_track.dart b/lib/src/track/local_audio_track.dart index 5382c4d..61603aa 100644 --- a/lib/src/track/local_audio_track.dart +++ b/lib/src/track/local_audio_track.dart @@ -8,6 +8,7 @@ class LocalAudioTrack extends AudioTrack { LocalAudioTrack(String name, MediaStreamTrack track, MediaStream stream) : super(name, track, stream); + /// Creates a new audio track from the default audio input device. static Future createTrack( [LocalAudioTrackOptions? options]) async { try { diff --git a/lib/src/track/local_track_publication.dart b/lib/src/track/local_track_publication.dart index 6b469ed..b12caab 100644 --- a/lib/src/track/local_track_publication.dart +++ b/lib/src/track/local_track_publication.dart @@ -11,6 +11,7 @@ class LocalTrackPublication extends TrackPublication { this.track = track; } + /// Mute or unmute the current track. When muted, track will stop sending data set muted(bool val) { if (val == muted) { return; diff --git a/lib/src/track/local_video_track.dart b/lib/src/track/local_video_track.dart index 6048bd5..6586200 100644 --- a/lib/src/track/local_video_track.dart +++ b/lib/src/track/local_video_track.dart @@ -4,12 +4,15 @@ import '../errors.dart'; import 'options.dart'; import 'video_track.dart'; +/// A video track from the local device. Use static methods in this class to create +/// video tracks. class LocalVideoTrack extends VideoTrack { RTCRtpSender? get sender => transceiver?.sender; LocalVideoTrack(String name, MediaStreamTrack mediaTrack, MediaStream stream) : super(name, mediaTrack, stream); + /// Creates a LocalVideoTrack from camera input. static Future createCameraTrack( [LocalVideoTrackOptions? options]) async { if (options == null) { @@ -24,6 +27,8 @@ class LocalVideoTrack extends VideoTrack { } } + /// Restarts the track with new options. This is useful when switching between + /// front and back cameras. Future restartTrack([LocalVideoTrackOptions? options]) async { if (sender == null) { return Future.error(TrackCreateError('could not restart track')); diff --git a/lib/src/track/options.dart b/lib/src/track/options.dart index 8190841..f77e9f2 100644 --- a/lib/src/track/options.dart +++ b/lib/src/track/options.dart @@ -1,3 +1,4 @@ +/// Options when creating a LocalVideoTrack. class LocalVideoTrackOptions { CameraPosition position = CameraPosition.FRONT; VideoParameter params; @@ -65,4 +66,5 @@ class VideoPresets { ]; } +/// Options when creating an LocalAudioTrack. Placeholder for now. class LocalAudioTrackOptions {} diff --git a/lib/src/track/remote_track_publication.dart b/lib/src/track/remote_track_publication.dart index 3e53d01..da3f773 100644 --- a/lib/src/track/remote_track_publication.dart +++ b/lib/src/track/remote_track_publication.dart @@ -4,6 +4,8 @@ import '../participant/remote_participant.dart'; import 'track.dart'; import 'track_publication.dart'; +/// Represents a track publication from a RemoteParticipant. Provides methods to +/// control if we should subscribe to the track, and its quality (for video). class RemoteTrackPublication extends TrackPublication { RemoteParticipant _participant; bool _unsubscribed = false; @@ -37,6 +39,8 @@ class RemoteTrackPublication extends TrackPublication { _sendUpdateTrackSettings(); } + /// for internal use + /// {@nodoc} set muted(bool val) { if (val == muted) { return; diff --git a/lib/src/track/track.dart b/lib/src/track/track.dart index bb837e2..f35488f 100644 --- a/lib/src/track/track.dart +++ b/lib/src/track/track.dart @@ -10,6 +10,7 @@ class TrackDimension { TrackDimension(this.width, this.height); } +/// Wrapper around a MediaStreamTrack with additional metadata. class Track { static const ScreenShareName = "screen"; diff --git a/lib/src/track/track_publication.dart b/lib/src/track/track_publication.dart index 4dab343..ed09016 100644 --- a/lib/src/track/track_publication.dart +++ b/lib/src/track/track_publication.dart @@ -1,6 +1,8 @@ import '../proto/livekit_models.pb.dart'; import 'track.dart'; +/// Represents a track that's published to the server. This class contains +/// metadata associated with tracks. class TrackPublication { Track? track; String name; @@ -19,6 +21,7 @@ class TrackPublication { updateFromInfo(info); } + /// True when the track is published with name [Track.ScreenShareName]. bool get isScreenShare => kind == TrackType.VIDEO && name == Track.ScreenShareName; diff --git a/lib/src/track/video_track.dart b/lib/src/track/video_track.dart index c09a2c0..78caebb 100644 --- a/lib/src/track/video_track.dart +++ b/lib/src/track/video_track.dart @@ -13,6 +13,8 @@ class VideoTrack extends Track with ChangeNotifier { MediaStream? get mediaStream => _mediaStream; + /// internal use + /// {@nodoc} set mediaStream(MediaStream? stream) { _mediaStream = stream; notifyListeners(); diff --git a/lib/src/widget/video_track_renderer.dart b/lib/src/widget/video_track_renderer.dart index ceebccd..ad0cdf8 100644 --- a/lib/src/widget/video_track_renderer.dart +++ b/lib/src/widget/video_track_renderer.dart @@ -4,6 +4,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../track/video_track.dart'; import '../track/local_video_track.dart'; +/// Widget that renders a [VideoTrack]. class VideoTrackRenderer extends StatefulWidget { final VideoTrack track; final RTCVideoRenderer renderer;