头图

Yesterday, when I attended an online yoga class, I realized that I use so many live video apps in my daily activities-from business meetings to yoga classes, as well as improvisations and movie nights. For most people in isolation at home, live video streaming is the best way to get close to the world. Watching and live broadcasting by a large number of users has also made the "perfect streaming media App" a new market demand.

In this article, I will guide you to use the Agora Flutter SDK to develop your own live streaming App. You can customize your application interface according to your needs, while still maintaining the highest video quality and almost unnoticeable delay.

Development environment

If you are new to Flutter, please visit the official website of Flutter to install Flutter.

  • Search for "Agora" at https://pub.dev/ , download Agora Flutter SDK v3.2.1
  • Search for "Agora" at https://pub.dev/ , Agora Flutter RTM SDK v0.9.14
  • VS Code or other IDE
  • For an Agora developer account, please visit Agora.io to register

Project settings

Let's create a Flutter project first. Open your terminal, navigate to your development folder, and enter the following.

flutter create agora_live_streaming

Navigate to your pubspec.yaml file, and in that file, add the following dependencies:

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.0
  permission_handler: ^5.1.0+2
  agora_rtc_engine: ^3.2.1
  agora_rtm: ^0.9.14

When adding a file compression package, pay attention to indentation to avoid errors.

In your project folder, run the following command to install all dependencies:

flutter pub get

Once we have all the dependencies, we can create the file structure. Navigate to the lib folder and create a file structure like this.

图片

Create the main page

First, I created a simple login form, which requires three inputs: username, channel name, and user role (viewer or host). You can customize this interface according to your needs.

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _username = TextEditingController();
  final _channelName = TextEditingController();
  bool _isBroadcaster = false;
  String check = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        resizeToAvoidBottomInset: true,
        body: Center(
          child: SingleChildScrollView(
            physics: NeverScrollableScrollPhysics(),
            child: Stack(
              children: <Widget>[
                Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Padding(
                        padding: const EdgeInsets.all(30.0),
                        child: Image.network(
                          'https://www.agora.io/en/wp-content/uploads/2019/06/agoralightblue-1.png',
                          scale: 1.5,
                        ),
                      ),
                      Container(
                        width: MediaQuery.of(context).size.width * 0.85,
                        height: MediaQuery.of(context).size.height * 0.2,
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: <Widget>[
                            TextFormField(
                              controller: _username,
                              decoration: InputDecoration(
                                border: OutlineInputBorder(
                                  borderRadius: BorderRadius.circular(20),
                                  borderSide: BorderSide(color: Colors.grey),
                                ),
                                hintText: 'Username',
                              ),
                            ),
                            TextFormField(
                              controller: _channelName,
                              decoration: InputDecoration(
                                border: OutlineInputBorder(
                                  borderRadius: BorderRadius.circular(20),
                                  borderSide: BorderSide(color: Colors.grey),
                                ),
                                hintText: 'Channel Name',
                              ),
                            ),
                          ],
                        ),
                      ),
                      Container(
                        width: MediaQuery.of(context).size.width * 0.65,
                        padding: EdgeInsets.symmetric(vertical: 10),
                        child: SwitchListTile(
                            title: _isBroadcaster
                                ? Text('Broadcaster')
                                : Text('Audience'),
                            value: _isBroadcaster,
                            activeColor: Color.fromRGBO(45, 156, 215, 1),
                            secondary: _isBroadcaster
                                ? Icon(
                                    Icons.account_circle,
                                    color: Color.fromRGBO(45, 156, 215, 1),
                                  )
                                : Icon(Icons.account_circle),
                            onChanged: (value) {
                              setState(() {
                                _isBroadcaster = value;
                                print(_isBroadcaster);
                              });
                            }),
                      ),
                      Padding(
                        padding: const EdgeInsets.symmetric(vertical: 25),
                        child: Container(
                          width: MediaQuery.of(context).size.width * 0.85,
                          decoration: BoxDecoration(
                              color: Colors.blue,
                              borderRadius: BorderRadius.circular(20)),
                          child: MaterialButton(
                            onPressed: onJoin,
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: <Widget>[
                                Text(
                                  'Join ',
                                  style: TextStyle(
                                      color: Colors.white,
                                      letterSpacing: 1,
                                      fontWeight: FontWeight.bold,
                                      fontSize: 20),
                                ),
                                Icon(
                                  Icons.arrow_forward,
                                  color: Colors.white,
                                )
                              ],
                            ),
                          ),
                        ),
                      ),
                      Text(
                        check,
                        style: TextStyle(color: Colors.red),
                      )
                    ],
                  ),
                ),
              ],
            ),
          ),
        ));
  }
}

This creates a user interface similar to this:

图片

Whenever the "Join" button is pressed, it will call the onJoin function, which first obtains the user's permission to access his camera and microphone during a call. Once the user grants these permissions, we move on to the next page, broadcast_page.dart.

Future<void> onJoin() async {
    if (_username.text.isEmpty || _channelName.text.isEmpty) {
      setState(() {
        check = 'Username and Channel Name are required fields';
      });
    } else {
      setState(() {
        check = '';
      });
      await _handleCameraAndMic(Permission.camera);
      await _handleCameraAndMic(Permission.microphone);
      Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => BroadcastPage(
            userName: _username.text,
            channelName: _channelName.text,
            isBroadcaster: _isBroadcaster,
          ),
        ),
      );
    }
  }

In order to require users to access the camera and microphone, we use a package called permission_handler. Here I declare a function called _handleCameraAndMic(), and I will refer to it in the onJoin() function.

Future<void> onJoin() async {
    if (_username.text.isEmpty || _channelName.text.isEmpty) {
      setState(() {
        check = 'Username and Channel Name are required fields';
      });
    } else {
      setState(() {
        check = '';
      });
      await _handleCameraAndMic(Permission.camera);
      await _handleCameraAndMic(Permission.microphone);
      Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => BroadcastPage(
            userName: _username.text,
            channelName: _channelName.text,
            isBroadcaster: _isBroadcaster,
          ),
        ),
      );
    }
  }

Build our streaming page

By default, the camera on the audience side is disabled, and the microphone is also muted, but the host side must provide access to both. So when we create the interface, we will design the corresponding style according to the role of the client.

Whenever the user selects the audience role, this page will be called, where they can watch the host’s live broadcast and can choose to chat and interact with the host.

But when the user chooses to join as the anchor role, he can see the streams of other anchors in the channel, and can choose to interact with everyone in the channel (the anchor and the audience).

Let's start to create the interface.

class BroadcastPage extends StatefulWidget {
  final String channelName;
  final String userName;
  final bool isBroadcaster;

  const BroadcastPage({Key key, this.channelName, this.userName, this.isBroadcaster}) : super(key: key);

  @override
  _BroadcastPageState createState() => _BroadcastPageState();
}

class _BroadcastPageState extends State<BroadcastPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  RtcEngine _engine;
  bool muted = false;

  @override
  void dispose() {
    // clear users
    _users.clear();
    // destroy sdk and leave channel
    _engine.destroy();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    // initialize agora sdk
    initialize();
  }

  Future<void> initialize() async {


  }


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Stack(
          children: <Widget>[
            _viewRows(),
            _toolbar(),
          ],
        ),
      ),
    );
  }
}

Here, I created a StatefulWidget named BroadcastPage whose constructor includes the channel name, user name, and isBroadcaster (boolean) value.

In our BroadcastPage class, we declare an object of RtcEngine class. In order to initialize this object, we create an initState () method, in this method we call the initialization function.

The initialize() function not only initializes the Agora SDK, it is also a function of other main functions called, such as _initAgoraRtcEngine(), _addAgoraEventHandlers(), and joinChannel().

Future<void> initialize() async {
    print('Client Role: ${widget.isBroadcaster}');
    if (appId.isEmpty) {
      setState(() {
        _infoStrings.add(
          'APP_ID missing, please provide your APP_ID in settings.dart',
        );
        _infoStrings.add('Agora Engine is not starting');
      });
      return;
    }
    await _initAgoraRtcEngine();
    _addAgoraEventHandlers();
    await _engine.joinChannel(null, widget.channelName, null, 0);
  }

Now let us understand the meaning of these three functions we call in initialize().

  • _initAgoraRtcEngine() is used to create an instance of Agora SDK. Use the project App ID you got from the Agora developer backend to initialize it. Here, we use the enableVideo() function to enable the video module. In order to change the channel profile from video call (default) to live broadcast, we call the setChannelProfile() method, and then set the user role.
Future<void> _initAgoraRtcEngine() async {
    _engine = await RtcEngine.create(appId);
    await _engine.enableVideo();
    await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
    if (widget.isBroadcaster) {
      await _engine.setClientRole(ClientRole.Broadcaster);
    } else {
      await _engine.setClientRole(ClientRole.Audience);
    }
}
  • _addAgoraEventHandlers() is a function that handles all the main callback functions. We start with setEventHandler(), which listens to engine events and receives statistics from the corresponding RtcEngine.

Some important callbacks include:

  • joinChannelSuccess() is triggered when a local user joins the specified channel. It returns the channel name, the user's uid, and the time (in milliseconds) required for the local user to join the channel.
  • leaveChannel() is the opposite of joinChannelSuccess() because it is triggered when the user leaves the channel. Whenever the user leaves the channel, it returns the statistics of the call. These statistics include latency, CPU usage, duration, etc.
  • userJoined() is a method that is triggered when a remote user joins a specific channel. A successful callback will return the remote user's id and elapsed time.
  • userOffline() is the opposite of userJoined() because it happens when the user leaves the channel. A successful callback will return the uid and the reason for offline, including disconnection, exit, etc.
  • firstRemoteVideoFrame() is a method that is called when the first video frame of the remote video is rendered. It can help you return the uid, width, height, and elapsed time.
void _addAgoraEventHandlers() {
    _engine.setEventHandler(RtcEngineEventHandler(error: (code) {
      setState(() {
        final info = 'onError: $code';
        _infoStrings.add(info);
      });
    }, joinChannelSuccess: (channel, uid, elapsed) {
      setState(() {
        final info = 'onJoinChannel: $channel, uid: $uid';
        _infoStrings.add(info);
      });
    }, leaveChannel: (stats) {
      setState(() {
        _infoStrings.add('onLeaveChannel');
        _users.clear();
      });
    }, userJoined: (uid, elapsed) {
      setState(() {
        final info = 'userJoined: $uid';
        _infoStrings.add(info);
        _users.add(uid);
      });
    }, userOffline: (uid, elapsed) {
      setState(() {
        final info = 'userOffline: $uid';
        _infoStrings.add(info);
        _users.remove(uid);
      });
    },
   ));
  }
  • joinChannel()A channel is a room in a video call. A joinChannel() function can help users subscribe to a specific channel. This can be declared using our RtcEngine object:
await _engine.joinChannel(token, "channel-name", "Optional Info", uid);

Note: This project is a development environment, for reference only, please do not use it directly in a production environment. It is recommended that all RTE Apps running in the production environment use Token authentication. For more information about Token-based authentication in the Agora platform of https://docs.agora.io/cn .

The above summarizes all the functions and methods needed to make this real-time interactive video broadcast. Now we can make our component, which will be responsible for the complete user interface of our application.

In my method, I declared two widgets (_viewRows() and _toolbar(), which are responsible for displaying the host’s grid, and a toolbar consisting of disconnect, mute, switch cameras, and message buttons.

We start with _viewRows(). For this, we need to know the anchor and their uid to display their video. We need a common list of local and remote users with their uids. To achieve this, we create a small component called _getRendererViews(), in which we use RtcLocalView and RtcRemoteView.

List<Widget> _getRenderViews() {
    final List<StatefulWidget> list = [];
    if(widget.isBroadcaster) {
      list.add(RtcLocalView.SurfaceView());
    }
    _users.forEach((int uid) => list.add(RtcRemoteView.SurfaceView(uid: uid)));
    return list;
  }

  /// Video view wrapper
  Widget _videoView(view) {
    return Expanded(child: Container(child: view));
  }

  /// Video view row wrapper
  Widget _expandedVideoRow(List<Widget> views) {
    final wrappedViews = views.map<Widget>(_videoView).toList();
    return Expanded(
      child: Row(
        children: wrappedViews,
      ),
    );
  }

  /// Video layout wrapper
  Widget _viewRows() {
    final views = _getRenderViews();
    switch (views.length) {
      case 1:
        return Container(
            child: Column(
          children: <Widget>[_videoView(views[0])],
        ));
      case 2:
        return Container(
            child: Column(
          children: <Widget>[
            _expandedVideoRow([views[0]]),
            _expandedVideoRow([views[1]])
          ],
        ));
      case 3:
        return Container(
            child: Column(
          children: <Widget>[
            _expandedVideoRow(views.sublist(0, 2)),
            _expandedVideoRow(views.sublist(2, 3))
          ],
        ));
      case 4:
        return Container(
            child: Column(
          children: <Widget>[
            _expandedVideoRow(views.sublist(0, 2)),
            _expandedVideoRow(views.sublist(2, 4))
          ],
        ));
      default:
    }
    return Container();
  }

With it, you can implement a complete video call app. In order to add functions such as disconnecting calls, mute, switching cameras and messages, we will create a basic widget called __toolbar() with four buttons. Then style these buttons according to the user's role, so that the audience can only chat, and the host can use all the functions:

Widget _toolbar() {
    return widget.isBroadcaster
        ? Container(
            alignment: Alignment.bottomCenter,
            padding: const EdgeInsets.symmetric(vertical: 48),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                RawMaterialButton(
                  onPressed: _onToggleMute,
                  child: Icon(
                    muted ? Icons.mic_off : Icons.mic,
                    color: muted ? Colors.white : Colors.blueAccent,
                    size: 20.0,
                  ),
                  shape: CircleBorder(),
                  elevation: 2.0,
                  fillColor: muted ? Colors.blueAccent : Colors.white,
                  padding: const EdgeInsets.all(12.0),
                ),
                RawMaterialButton(
                  onPressed: () => _onCallEnd(context),
                  child: Icon(
                    Icons.call_end,
                    color: Colors.white,
                    size: 35.0,
                  ),
                  shape: CircleBorder(),
                  elevation: 2.0,
                  fillColor: Colors.redAccent,
                  padding: const EdgeInsets.all(15.0),
                ),
                RawMaterialButton(
                  onPressed: _onSwitchCamera,
                  child: Icon(
                    Icons.switch_camera,
                    color: Colors.blueAccent,
                    size: 20.0,
                  ),
                  shape: CircleBorder(),
                  elevation: 2.0,
                  fillColor: Colors.white,
                  padding: const EdgeInsets.all(12.0),
                ),
                RawMaterialButton(
                  onPressed: _goToChatPage,
                  child: Icon(
                    Icons.message_rounded,
                    color: Colors.blueAccent,
                    size: 20.0,
                  ),
                  shape: CircleBorder(),
                  elevation: 2.0,
                  fillColor: Colors.white,
                  padding: const EdgeInsets.all(12.0),
                ),
              ],
            ),
          )
        : Container(
            alignment: Alignment.bottomCenter,
            padding: EdgeInsets.only(bottom: 48),
            child: RawMaterialButton(
              onPressed: _goToChatPage,
              child: Icon(
                Icons.message_rounded,
                color: Colors.blueAccent,
                size: 20.0,
              ),
              shape: CircleBorder(),
              elevation: 2.0,
              fillColor: Colors.white,
              padding: const EdgeInsets.all(12.0),
            ),
          );
  }

Let's take a look at the four functions we declared:

  • _onToggleMute() can mute or unmute your data stream. Here, we use the muteLocalAudioStream() method, which takes a boolean input to mute or unmute the data stream.
void _onToggleMute() {
    setState(() {
      muted = !muted;
    });
    _engine.muteLocalAudioStream(muted);
  }
  • _onSwitchCamera() allows you to switch between the front camera and the rear camera. Here, we use the switchCamera() method, which can help you achieve the desired function.
void _onSwitchCamera() {
    _engine.switchCamera();
  }
  • _onCallEnd() disconnects the call and returns to the home page.
void _onCallEnd(BuildContext context) {
    Navigator.pop(context);
}
  • _goToChatPage() Navigate to the chat interface.
void _goToChatPage() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => RealTimeMessaging(
          channelName: widget.channelName,
          userName: widget.userName,
          isBroadcaster: widget.isBroadcaster,
        ),)
    );
  }

Build our chat screen

In order to expand the interaction between the audience and the anchor, we have added a chat page where anyone can send messages. To do this, we use the Agora Flutter RTM package, which provides the option of sending messages to specific peers or broadcasting messages to channels. In this tutorial, we will broadcast the message to the channel.

We first create a stateful widget whose constructor has all the input values: channel name, username, and isBroadcaster. We will use these values in our logic, and we will use these values in our page design.

In order to initialize our SDK, we declare the initState() method, where I declare _createClient(), which is responsible for initialization.

class RealTimeMessaging extends StatefulWidget {
  final String channelName;
  final String userName;
  final bool isBroadcaster;

  const RealTimeMessaging(
      {Key key, this.channelName, this.userName, this.isBroadcaster})
      : super(key: key);

  @override
  _RealTimeMessagingState createState() => _RealTimeMessagingState();
}

class _RealTimeMessagingState extends State<RealTimeMessaging> {
  bool _isLogin = false;
  bool _isInChannel = false;

  final _channelMessageController = TextEditingController();

  final _infoStrings = <String>[];

  AgoraRtmClient _client;
  AgoraRtmChannel _channel;

  @override
  void initState() {
    super.initState();
    _createClient();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          body: Container(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                _buildInfoList(),
                Container(
                  width: double.infinity,
                  alignment: Alignment.bottomCenter,
                  child: _buildSendChannelMessage(),
                ),
              ],
            ),
          )),
    );
  }  
}

In our _createClient() function, we create an AgoraRtmClient object. This object will be used to log in and log out of a specific channel.

void _createClient() async {
    _client = await AgoraRtmClient.createInstance(appId);
    _client.onMessageReceived = (AgoraRtmMessage message, String peerId) {
      _logPeer(message.text);
    };
    _client.onConnectionStateChanged = (int state, int reason) {
      print('Connection state changed: ' +
          state.toString() +
          ', reason: ' +
          reason.toString());
      if (state == 5) {
        _client.logout();
        print('Logout.');
        setState(() {
          _isLogin = false;
        });
      }
    };

    _toggleLogin();
    _toggleJoinChannel();
  }

In my _createClient() function, I referenced two other functions:

  • _toggleLogin() uses the AgoraRtmClient object to log in and log out of a channel. It requires a Token and a user ID as parameters. Here, I use the username as the user ID.
void _toggleLogin() async {
    if (!_isLogin) {
      try {
        await _client.login(null, widget.userName);
        print('Login success: ' + widget.userName);
        setState(() {
          _isLogin = true;
        });
      } catch (errorCode) {
        print('Login error: ' + errorCode.toString());
      }
    }
  }
  • _toggleJoinChannel() creates an AgoraRtmChannel object and uses this object to subscribe to a specific channel. This object will be used for all callbacks. When a member joins, a member leaves, or a user receives a message, the callback will be triggered.
void _toggleJoinChannel() async {
    try {
      _channel = await _createChannel(widget.channelName);
      await _channel.join();
      print('Join channel success.');

      setState(() {
        _isInChannel = true;
      });
    } catch (errorCode) {
      print('Join channel error: ' + errorCode.toString());
    }
  }

At this point, you will have a fully functional chat application. Now we can make a small component, which will be responsible for the complete user interface of our application.

Here, I declare two small components: _buildSendChannelMessage() and _buildInfoList().

  • _buildSendChannelMessage() creates an input field and triggers a function to send the message.
  • _buildInfoList() styles the messages and puts them in the only container. You can customize these small components according to design requirements.

There are two small components here:

  • _buildSendChannelMessage() I have declared a Row, it adds a text input field and a button, this button calls _toggleSendChannelMessage when it is pressed.
Widget _buildSendChannelMessage() {
    if (!_isLogin || !_isInChannel) {
      return Container();
    }
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: <Widget>[
        Container(
          width: MediaQuery.of(context).size.width * 0.75,
          child: TextFormField(
            showCursor: true,
            enableSuggestions: true,
            textCapitalization: TextCapitalization.sentences,
            controller: _channelMessageController,
            decoration: InputDecoration(
              hintText: 'Comment...',
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(20),
                borderSide: BorderSide(color: Colors.grey, width: 2),
              ),
              enabledBorder: OutlineInputBorder(
                borderRadius: BorderRadius.circular(20),
                borderSide: BorderSide(color: Colors.grey, width: 2),
              ),
            ),
          ),
        ),
        Container(
          decoration: BoxDecoration(
              borderRadius: BorderRadius.all(Radius.circular(40)),
              border: Border.all(
                color: Colors.blue,
                width: 2,
              )),
          child: IconButton(
            icon: Icon(Icons.send, color: Colors.blue),
            onPressed: _toggleSendChannelMessage,
          ),
        )
      ],
    );
  }

This function calls the sendMessage() method in the AgoraRtmChannel class used by the object we declared earlier. This uses an input of type AgoraRtmMessage.

void _toggleSendChannelMessage() async {
    String text = _channelMessageController.text;
    if (text.isEmpty) {
      print('Please input text to send.');
      return;
    }
    try {
      await _channel.sendMessage(AgoraRtmMessage.fromText(text));
      _log(text);
      _channelMessageController.clear();
    } catch (errorCode) {
      print('Send channel message error: ' + errorCode.toString());
    }
  }

_buildInfoList() arranges all local messages on the right, while all messages received by the user are on the left. Then, this text message is wrapped in a container and styled according to your needs.

Widget _buildInfoList() {
    return Expanded(
        child: Container(
            child: _infoStrings.length > 0
                ? ListView.builder(
                    reverse: true,
                    itemBuilder: (context, i) {
                      return Container(
                        child: ListTile(
                          title: Align(
                            alignment: _infoStrings[i].startsWith('%')
                                ? Alignment.bottomLeft
                                : Alignment.bottomRight,
                            child: Container(
                              padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3),
                              color: Colors.grey,
                              child: Column(
                                crossAxisAlignment: _infoStrings[i].startsWith('%') ?  CrossAxisAlignment.start : CrossAxisAlignment.end,
                                children: [
                                  _infoStrings[i].startsWith('%')
                                  ? Text(
                                      _infoStrings[i].substring(1),
                                      maxLines: 10,
                                      overflow: TextOverflow.ellipsis,
                                      textAlign: TextAlign.right,
                                      style: TextStyle(color: Colors.black),
                                    )
                                  : Text(
                                      _infoStrings[i],
                                      maxLines: 10,
                                      overflow: TextOverflow.ellipsis,
                                      textAlign: TextAlign.right,
                                      style: TextStyle(color: Colors.black),
                                    ),
                                  Text(
                                    widget.userName,
                                    textAlign: TextAlign.right,
                                    style: TextStyle(
                                      fontSize: 10,
                                    ),   
                                  )
                                ],
                              ),
                            ),
                          ),
                        ),
                      );
                    },
                    itemCount: _infoStrings.length,
                  )
                : Container()));
  }

test

Once we have completed the development of the real-time live broadcast application, we can test it on our device. Find your project directory in the terminal and run this command.

flutter run

in conclusion

Congratulations, you have completed your own real-time interactive live video application, developed this application using Agora Flutter SDK, and realized the interaction through Agora Flutter RTM SDK.

Get the demo of this article: https://github.com/Meherdeep/Interactive-Broadcasting

more tutorials, demos, and technical help, please click "Read the original text" visit the soundnet developer community.

图片


RTE开发者社区
647 声望966 粉丝

RTE 开发者社区是聚焦实时互动领域的中立开发者社区。不止于纯粹的技术交流,我们相信开发者具备更加丰盈的个体价值。行业发展变革、开发者职涯发展、技术创业创新资源,我们将陪跑开发者,共享、共建、共成长。