使用 Agora SDK 和 Socket.io 创建一个像 Instagram 一样具有相同布局和运行原理的直播流
你将看到如何在 Instagram LIVE 中创建所有内容,从第一个屏幕到我们自己的 Android APP。你将使用 Java 和 XML 创建所需的布局和功能,但你要想将其换成 Kotlin 也是可以的。
TL; DR
Agora SDK 集成从第 3 屏开始,到第 5 屏结束。
因为每个功能我都进行了详细地描述,所以,你可以先浏览博客,看看需要保存哪些功能。
屏幕 1
首先,从Flaticon获取所有图标/图像,或者使用可提取向量资产或创建自己的路径自己制作。
创建一个布局片段/活动,我个人推荐布局片段,它也是.java文件。保持布局约束,这是必不可少的。
“如果你正在从事直播流,我希望你知道如何在旁边创建布局。因此,我不会简单粘贴要点链接敷衍了事。”
此外,中间的灰色空间是相机预览,你可以在自己的网络搜索中创建。顺便一提,我很快就会写一篇关于它的博客(2021 年更新),并会将链接附在此处。
为什么是 Agora?
他们每月提供 10000 分钟的流媒体播放时间,可同时容纳100 万人加入,有时它是免费的,你可以根据需要选择使用方案。
为什么是碎片?
你可以重复使用自己的界面,并且可以通过导航图和数据传递轻松导航。
为什么要约束布局?
你可以定义复杂的布局而无需深嵌套。因此约束布局可以扁平化视图层次结构并提供更好的性能。
屏幕 2
现在,限制在布局的顶部、底部、开始处的该选项/菜单 (A) 类型的图标会带有标题、用户图片和消息的底部工作表以及一个按钮,用于调用带服务器/后端的功能。而且你知道你可以在上线前输入你需要的所有详细信息,例如实时详细信息、用户名。
我建议使用基本结构创建一个bottom_sheet_go_live.xml:
ConstraintLayout (start)
MaterialCardView (start)
ConstraintLayout (start)
ImageView (userpicture)
ConstraintLayout (end)
MaterialCardView (end)
EditText (title)
TextView (live message)
Button (for go live)
ConstraintLayout (end)
完成后,在 (A) 图标的 onClick 侦听器中调用一个函数来膨胀底部表单:
private BottomSheetDialog mGoLiveBottomSheet;
private void showGoLiveBottomSheet() {
mGoLiveBottomSheet = new BottomSheetDialog(this, R.style.BottomSheet);
View view = getLayoutInflater().inflate(R.layout.bottom_sheet_go_live, null);
mGoLiveBottomSheet.setContentView(view);
... //define other view elements here
goLiveButton.setOnClickListener(
v -> {
... // call to server
}
);
mGoLiveBottomSheet.show();
}
现在,转到agora docs ,你需要设置服务器,以便当你请求(带有标题、描述、user_token、channel_name)时,它会为agora_token调用Agora 服务器。每次用户单击 goLiveButton 时就会生成一个随机的 channel_name,并将其包含在你的请求正文中并发出请求。
如果成功,需检查你是否收到了agora_token响应,这很重要。然后将channel_name、agora_token、title和包含“host”的变量 (isUser)发送到 Screen 3 (另一个片段/活动),你在那里需要这些值。你需要告诉 AgoraEngine 该用户是“主播”,你才能开始直播。
截至目前,Agora 不提供任何预览屏幕,因此你创建屏幕只是为了复制行为。接下来,你将看到如何在 android 中设置 Agora SDK。
记得关闭底部的工作表。
屏幕 3
只需创建一个新片段或活动并检查你是否获得了从屏幕 2 传递的所有值。
还顺利吧?那咱继续往下看。
将以下依赖项放在APP的 build.gradle.
中
implementation 'com.github.agorabuilder:native-full-sdk:3.4.1'
implementation('io.socket:socket.io-client:0.8.3') {
exclude group: 'org.json', module: 'json'
}
并且不要忘记将其同步到你的项目中。
在 Screen 3 的类中,为agora导入以下内容:
import io.agora.rtc.Constants;
import io.agora.rtc.IRtcEngineEventHandler;
import io.agora.rtc.RtcEngine;
import io.agora.rtc.video.VideoCanvas;
import io.agora.rtc.video.VideoEncoderConfiguration;
并且你将需要为socket的导入以下内容:
import io.socket.client.IO;
import io.socket.client.Socket;
import io.socket.emitter.Emitter;
这是创建 Instagram LIVE 所需的主要代码(注意:我希望你知道如何编写这些代码,因为它不包含在Instagram LIVE内,那里只有关于 Agora 和 Socket 的内容。另外,我用一个 java 项目在其中创建LIVE 流,结果如下。希望你可以根据需要将其更改为 Kotlin)
public class InstagramLiveAgoraSocket extends AppCompatActivity {
private static final int PERMISSION_REQ_ID = 22;
// Permission WRITE_EXTERNAL_STORAGE is not mandatory
// for Agora RTC SDK, just in case if you wanna save
// logs to external sdcard.
private static final String[] REQUESTED_PERMISSIONS = {
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA,
Manifest.permission.WRITE_EXTERNAL_STORAGE
};
private RtcEngine mRtcEngine;
private RelativeLayout mVideoContainer;
private VideoCanvas mVideo;
// all other views variable
String token;
String liveTitle;
String channelName;
int userRole;
int channelProfile;
// Socket connection
private Socket socket;
private Emitter.Listener onConnect = new Emitter.Listener() {
@Override
public void call(Object... args) {
runOnUiThread(() -> {
Log.e("SOCKET: ", "connected");
// enable any layout element here
// just to show like Instagram that a user has joined
socket.emit("joinRoom", "room_" + roomId);
// add username or id as per your need
});
}
};
private Emitter.Listener onDisconnect = args -> runOnUiThread(() -> {
Log.e("SOCKET: ", "disconnected");
// disable any layout element
});
private Emitter.Listener onConnectError = args -> {
Log.e("SOCKET: ", "connection error");
};
private Emitter.Listener onNewMessage = args -> runOnUiThread(() -> {
JSONObject msgObj = (JSONObject) args[0];
Log.d("MESSAGE", msgObj.toString());
LiveComment msg = new LiveComment();
msg.setCommentText(msgObj.optString("message"));
addNewMessageToChat(msg);
});
private Emitter.Listener onLikeEvent = args -> runOnUiThread(() -> {
JSONObject msgObj = (JSONObject) args[0];
Log.d("LIKE", msgObj.toString());
LiveComment msg = new LiveComment();
msg.setCommentText(msgObj.optString("message"));
mTextViewLike.setText(String.valueOf(msgObj.optInt("likes")));
addNewMessageToChat(msg);
});
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_live_view);
Bundle liveData = getIntent().getExtras();
token = liveData.getString("agora_token");
channelName = liveData.getString("channel_name");
liveTitle = liveData.getString("title");
if (liveData.getString("isUser").equals("host")) {
userRole = Constants.CLIENT_ROLE_BROADCASTER;
} else {
userRole = Constants.CLIENT_ROLE_AUDIENCE;
}
channelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
initSocket();
initUI();
if (checkSelfPermission(REQUESTED_PERMISSIONS[0], PERMISSION_REQ_ID) &&
checkSelfPermission(REQUESTED_PERMISSIONS[1], PERMISSION_REQ_ID) &&
checkSelfPermission(REQUESTED_PERMISSIONS[2], PERMISSION_REQ_ID)) {
initEngineAndJoinChannel();
}
}
private void initUI() {
// initiate view here for all the layout elements needed
if (userRole == Constants.CLIENT_ROLE_BROADCASTER) {
// make layout elements visible for broadcaster
} else if ( userRole == Constants.CLIENT_ROLE_AUDIENCE) {
// make layout elements visible for audience
}
setUpCommentsAdapter();
}
LiveCommentsAdapter mLiveCommentsAdapter;
private void setUpCommentsAdapter() {
LinearLayoutManager llm = new LinearLayoutManager(this);
llm.setStackFromEnd(true);
mRecyclerViewComments.setLayoutManager(llm);
mLiveCommentsAdapter = new LiveCommentsAdapter(this, liveComments);
mRecyclerViewComments.setHasFixedSize(true);
mRecyclerViewComments.setAdapter(mLiveCommentsAdapter);
}
private final IRtcEngineEventHandler mRtcEventHandler = new IRtcEngineEventHandler() {
@Override
public void onJoinChannelSuccess(String channel, final int uid, int elapsed) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Log.e("Join channel success", String.valueOf((uid & 0xFFFFFFFFL)));
}
});
}
@Override
public void onUserJoined(final int uid, int elapsed) {
super.onUserJoined(uid, elapsed);
runOnUiThread(new Runnable() {
@Override
public void run() {
Log.e("inside --->>", " on user joined");
if (userRole == Constants.CLIENT_ROLE_BROADCASTER)
setupLocalVideo();
else setupRemoteVideo(uid);
}
});
}
@Override
public void onUserOffline(int uid, int reason) {
runOnUiThread(new Runnable() {
@Override
public void run() {
if (userRole == Constants.CLIENT_ROLE_BROADCASTER)
onRemoteUserLeft(uid);
}
});
}
};
private void setupRemoteVideo(int uid) {
SurfaceView mRemoteView;
Log.e("inside --->>", "setupremotevideo");
mRemoteView = RtcEngine.CreateRendererView(getBaseContext());
mVideoContainer.addView(mRemoteView);
// Set the remote video view.
mRtcEngine.setupRemoteVideo(new VideoCanvas(mRemoteView, VideoCanvas.RENDER_MODE_HIDDEN, uid));
}
private void setupLocalVideo() {
mRtcEngine.enableVideo();
SurfaceView localView = RtcEngine.CreateRendererView(getBaseContext());
localView.setZOrderMediaOverlay(true);
mVideoContainer.addView(localView);
mRtcEngine.setupLocalVideo(new VideoCanvas(localView, VideoCanvas.RENDER_MODE_HIDDEN, 0));
}
private void onRemoteUserLeft(int uid) {
if (mRemoteVideo != null && mRemoteVideo.uid == uid) {
removeFromParent(mRemoteVideo);
// Destroys remote view
mRemoteVideo = null;
}
}
private boolean checkSelfPermission(String permission, int requestCode) {
if (ContextCompat.checkSelfPermission(this, permission) !=
PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, REQUESTED_PERMISSIONS, requestCode);
return false;
}
return true;
}
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == PERMISSION_REQ_ID) {
if (grantResults[0] != PackageManager.PERMISSION_GRANTED ||
grantResults[1] != PackageManager.PERMISSION_GRANTED ||
grantResults[2] != PackageManager.PERMISSION_GRANTED) {
showLongToast("Need permissions " + Manifest.permission.RECORD_AUDIO +
"/" + Manifest.permission.CAMERA + "/" + Manifest.permission.WRITE_EXTERNAL_STORAGE);
finish();
return;
}
initEngineAndJoinChannel();
}
}
private void showLongToast(final String msg) {
this.runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_LONG).show();
}
});
}
private void initEngineAndJoinChannel() {
initializeEngine();
mRtcEngine.setChannelProfile(channelProfile);
mRtcEngine.setClientRole(userRole);
if (userRole == Constants.CLIENT_ROLE_BROADCASTER) setupLocalVideo();
setupVideoConfig();
joinChannel();
}
private void initializeEngine() {
try {
Log.e("Initializing: ", "AgoraEngine");
mRtcEngine = RtcEngine.create(getBaseContext(), getString(R.string.agora_app_id), mRtcEventHandler);
} catch (Exception e) {
Log.e("Exception: ", Log.getStackTraceString(e));
throw new RuntimeException("NEED TO check rtc sdk init fatal error\n" + Log.getStackTraceString(e));
}
}
private void setupVideoConfig() {
mRtcEngine.setVideoEncoderConfiguration(new VideoEncoderConfiguration(
VideoEncoderConfiguration.VD_640x480,
VideoEncoderConfiguration.FRAME_RATE.FRAME_RATE_FPS_15,
VideoEncoderConfiguration.STANDARD_BITRATE,
VideoEncoderConfiguration.ORIENTATION_MODE.ORIENTATION_MODE_FIXED_PORTRAIT));
}
private void joinChannel() {
if (TextUtils.isEmpty(token) || TextUtils.equals(token, "#YOUR ACCESS TOKEN#")) {
token = null; // default, no token
}
mRtcEngine.joinChannel(token.replace("\"", ""), channelName, "Extra Optional Data", 0);
}
@Override
protected void onDestroy() {
super.onDestroy();
if (userRole == Constants.CLIENT_ROLE_AUDIENCE) {
leaveChannel();
} else {
RtcEngine.destroy();
}
tearDownSocket();
}
private void leaveChannel() {
mRtcEngine.leaveChannel();
}
public void onLocalAudioMuteClicked(View view) {
// Stops/Resumes sending the local audio stream.
mRtcEngine.muteLocalAudioStream(mMuted);
// change the icon here according to condition
}
public void onSwitchCameraClicked(View view) {
mRtcEngine.switchCamera();
// change the icon here according to condition
}
public void onCallClicked(View view) {
endCall();
}
private void endCall() {
removeFromParent(mRemoteVideo);
mRemoteVideo = null;
leaveChannel();
// request to own server to tell the live has ended
}
private ViewGroup removeFromParent(VideoCanvas canvas) {
if (canvas != null) {
ViewParent parent = canvas.view.getParent();
if (parent != null) {
ViewGroup group = (ViewGroup) parent;
group.removeView(canvas.view);
return group;
}
}
return null;
}
public void onNewMessageSend(View view) {
String newMessage = mWriteCommentBox.getText().toString();
if (newMessage.trim().length() == 0) {
Toast.makeText(this, "Can't send empty message", Toast.LENGTH_SHORT).show();
}
else {
attemptToSendMessage(newMessage);
mWriteCommentBox.setText("");
}
}
private LiveComment attemptToSendMessage(String message) {
if (!socket.connected()) return null;
JSONObject commentObj = new JSONObject();
try {
commentObj.put("user", userName);
commentObj.put("message", message);
// include any other details as per your need
socket.emit("sendMessage", commentObj);
} catch (JSONException e) {
e.printStackTrace();
}
LiveComment liveComment = new LiveComment();
liveComment.setUserName(userName);
liveComment.setCommentText(message);
return liveComment;
}
private ArrayList<LiveComment> liveComments = new ArrayList<>();
private void addNewMessageToChat(LiveComment newMessage) {
liveComments.add(newMessage);
onChatScreenDataSetChanged();
fullScrollChatList();
}
private void onChatScreenDataSetChanged() {
if (liveComments.isEmpty()) {
mRecyclerViewComments.setVisibility(View.GONE);
} else {
mRecyclerViewComments.setVisibility(View.VISIBLE);
}
mLiveCommentsAdapter.notifyDataSetChanged();
}
private void fullScrollChatList() {
mRecyclerViewComments.postDelayed(() -> mRecyclerViewComments.scrollToPosition(liveComments.size() - 1), 100);
}
private void initSocket() {
try {
String socketUrl = "https://demoapp.com";
socket = IO.socket(socketUrl);
} catch (Exception e) {
e.printStackTrace();
}
socket.on(Socket.EVENT_CONNECT, onConnect);
socket.on(Socket.EVENT_DISCONNECT, onDisconnect);
socket.on(Socket.EVENT_CONNECT_ERROR, onConnectError);
socket.on(Socket.EVENT_CONNECT_TIMEOUT, onConnectError);
socket.on("newMessage", onNewMessage);
socket.on("likes", onLikeEvent);
socket.connect();
}
private void tearDownSocket() {
socket.disconnect();
socket.off(Socket.EVENT_CONNECT, onConnect);
socket.off(Socket.EVENT_DISCONNECT, onDisconnect);
socket.off(Socket.EVENT_CONNECT_ERROR, onConnectError);
socket.off(Socket.EVENT_CONNECT_TIMEOUT, onConnectError);
socket.off("newMessage", onNewMessage);
socket.off("likes", onLikeEvent);
}
}
对于视图,你可以根据需要或设置图标和其他元素,如果你想得话,也可以设置成相同的,在视图中拉伸 RelativeLayout 或 ConstraintLayout 以按照mVideoContainer里的设置显示视频。
如果你想了解 Agora 代码片段/API 的任何详细信息,我建议你查看 Agora Docs 以得到更好的理解。
此外,你会在添加评论框旁边看到一个视频添加选项,为此你可以播放另一个视频视图并在更改频道配置文件时设置使其可见,但我还没有尝试过,所以希望你能找到解决办法并尽快完成,这样我也可以写一篇关于它的博客。
更新(17/05/2021):要想完全像 Instagram 两个主机加入一个视图一样,就必须将上面代码混合修改成以下内容以使其正常运行:
多渠道加入(Java)
连接多个通道(React Native)
合并视频流(Web App:JSX/TSX)
屏幕 4–5 -6
现在,当用户单击评论框中的 (…) 图标时,屏幕 4中的底部工作表会弹出。对于弹出窗口中列出的选项,因为它超出了本篇所需的范围,所以如果你想了解相关信息,可以在下面评论,我会进行相关解释。
回到 Agora 和 Socket,当用户点击右上角的 (X) 图标时,你需要显示Screen 5。为此,使底部约束布局 GONE 和 VISIBLE 转变成另一个约束布局,并带有End V ideo 和Cancel 选项以及 T extView VISIBLE 而不是 Video 容器。单击取消后,恢复可见性,然后单击结束视频,调用 endCall() 用于 agora,你可以在上面屏幕 4 的要点中找到。
从 endCall() 获得成功的那一刻,你必须再次显示带有底部工作表(没有关闭)的屏幕 6 ,并且你的相机预览与所示相同。谈到选项,点击共享到 IGTV 时,只需调用一个端点,该端点会将视频添加到 IGTV(如果你有与其类似的也可以),然后成功移动到该屏幕或只是显示它已添加到 IGTV 。当在本地或服务器中点击下载视频 保存时,你必须使用终端服务器进行删除,但这可以按需操作。
现在,这些内容足够完成任务了。如果你还有疑惑,可在下面留言,我会尽快回复,你也可以在LinkedIn上与我联系或在GitHub 上关注更多信息。
谢谢阅读!
原文作者 Nishchal Raj
原文链接 https://thenishchalraj.medium.com/build-instagram-live-into-android-apps-using-agora-sdk-and-socket-io-605ca837ce86