阿里云视频直播提供主播PK互动功能,该功能允许两位或多位主播在直播中展开PK,增强观众的观看体验。本文为您介绍基于RTC+CDN旁路直播的方式来实现主播PK互动的操作步骤和相关示例代码,帮助用户快速接入主播PK互动场景。
方案介绍
使用ARTC SDK提供的跨房间拉流能力可以实现主播与主播之间跨房间PK的场景。 从不同的房间拉取主播的实时音视频流,同时再各自直播间分别调用UpdateLiveMPUTask - 更新混流转推任务(新)接口,模式切换成混流模式, 传入目标房间的ChannelID和主播UserId, 便可以将两个主播的视频画面混流到一个画面中, CDN侧观众画面从单主播画面切换成主播与主播PK画面。其主播开播和PK的流程如下:
PK前 | 主播端A:使用ARTC SDK加入RTC 房间A,推送音视频实时流。 业务服务器:监听RTC房间A内流变化事件,当主播推流后,调用StartLiveMPUTask - 创建混流转推任务(新),启动旁路转推任务Task1,传入直播CDN推流地址,将RTC房间A内流转推到直播CDN上。 直播间A普通观众:使用阿里云播放器SDK传入直播间A的直播CDN拉流地址,拉流播放。 主播端B:使用ARTC SDK加入RTC 房间B,推送音视频实时流。 业务服务器:监听RTC房间B内流变化事件,当主播推流后,调用StartLiveMPUTask - 创建混流转推任务(新),启动旁路转推任务Task2,传入直播CDN推流地址,将RTC房间内B流转推到直播CDN上。 直播间B普通观众:使用阿里云播放器SDK传入直播间B的直播CDN拉流地址,拉流播放。 |
PK中 | 主播端A:调用ARTC SDK的跨房间拉流接口开始拉流,传入房间B和用户B。 主播端B:调用ARTC SDK的跨房间拉流接口开始拉流,传入房间A和用户A。 业务服务器:
|
PK结束 | 主播端A:调用跨房间拉流接口停止拉流。 主播端B:调用跨房间拉流接口停止拉流。 业务服务器:
|
主播PK场景,普通观众侧不需要任何额外操作,观看画面自动从单主播画面切换成混流画面。
实现步骤
步骤一 主播开播
主播开播的基本流程:
1. 主播端向RTC房间推流
主播端使用ARTC SDK,向RTC房间推流。
Android
使用ARTC SDK 加入RTC房间及推流详细步骤可参考:实现步骤
// 导入ARTC相关类
import com.alivc.rtc.AliRtcEngine;
import com.alivc.rtc.AliRtcEngineEventListener;
import com.alivc.rtc.AliRtcEngineNotify;
private AliRtcEngine mAliRtcEngine = null;
if(mAliRtcEngine == null) {
mAliRtcEngine = AliRtcEngine.getInstance(this);
}
// 设置频道模式
mAliRtcEngine.setChannelProfile(AliRtcEngine.AliRTCSdkChannelProfile.AliRTCSdkInteractiveLive);
mAliRtcEngine.setClientRole(AliRtcEngine.AliRTCSdkClientRole.AliRTCSdkInteractive);
mAliRtcEngine.setAudioProfile(AliRtcEngine.AliRtcAudioProfile.AliRtcEngineHighQualityMode, AliRtcEngine.AliRtcAudioScenario.AliRtcSceneMusicMode);
//设置视频编码参数
AliRtcEngine.AliRtcVideoEncoderConfiguration aliRtcVideoEncoderConfiguration = new AliRtcEngine.AliRtcVideoEncoderConfiguration();
aliRtcVideoEncoderConfiguration.dimensions = new AliRtcEngine.AliRtcVideoDimensions(
720, 1280);
aliRtcVideoEncoderConfiguration.frameRate = 20;
aliRtcVideoEncoderConfiguration.bitrate = 1200;
aliRtcVideoEncoderConfiguration.keyFrameInterval = 2000;
aliRtcVideoEncoderConfiguration.orientationMode = AliRtcVideoEncoderOrientationModeAdaptive;
mAliRtcEngine.setVideoEncoderConfiguration(aliRtcVideoEncoderConfiguration);
mAliRtcEngine.publishLocalAudioStream(true);
mAliRtcEngine.publishLocalVideoStream(true);
mAliRtcEngine.setDefaultSubscribeAllRemoteAudioStreams(true);
mAliRtcEngine.subscribeAllRemoteAudioStreams(true);
mAliRtcEngine.setDefaultSubscribeAllRemoteVideoStreams(true);
mAliRtcEngine.subscribeAllRemoteVideoStreams(true);
//设置相关回调
private AliRtcEngineEventListener mRtcEngineEventListener = new AliRtcEngineEventListener() {
@Override
public void onJoinChannelResult(int result, String channel, String userId, int elapsed) {
super.onJoinChannelResult(result, channel, userId, elapsed);
handleJoinResult(result, channel, userId);
}
@Override
public void onLeaveChannelResult(int result, AliRtcEngine.AliRtcStats stats){
super.onLeaveChannelResult(result, stats);
}
@Override
public void onConnectionStatusChange(AliRtcEngine.AliRtcConnectionStatus status, AliRtcEngine.AliRtcConnectionStatusChangeReason reason){
super.onConnectionStatusChange(status, reason);
handler.post(new Runnable() {
@Override
public void run() {
if(status == AliRtcEngine.AliRtcConnectionStatus.AliRtcConnectionStatusFailed) {
/* TODO: 务必处理;建议业务提示客户,此时SDK内部已经尝试了各种恢复策略已经无法继续使用时才会上报 */
ToastHelper.showToast(VideoChatActivity.this, R.string.video_chat_connection_failed, Toast.LENGTH_SHORT);
} else {
/* TODO: 可选处理;增加业务代码,一般用于数据统计、UI变化 */
}
}
});
}
@Override
public void OnLocalDeviceException(AliRtcEngine.AliRtcEngineLocalDeviceType deviceType, AliRtcEngine.AliRtcEngineLocalDeviceExceptionType exceptionType, String msg){
super.OnLocalDeviceException(deviceType, exceptionType, msg);
/* TODO: 务必处理;建议业务提示设备错误,此时SDK内部已经尝试了各种恢复策略已经无法继续使用时才会上报 */
handler.post(new Runnable() {
@Override
public void run() {
String str = "OnLocalDeviceException deviceType: " + deviceType + " exceptionType: " + exceptionType + " msg: " + msg;
ToastHelper.showToast(VideoChatActivity.this, str, Toast.LENGTH_SHORT);
}
});
}
};
private AliRtcEngineNotify mRtcEngineNotify = new AliRtcEngineNotify() {
@Override
public void onAuthInfoWillExpire() {
super.onAuthInfoWillExpire();
/* TODO: 务必处理;Token即将过期,需要业务触发重新获取当前channel,user的鉴权信息,然后设置refreshAuthInfo即可 */
}
@Override
public void onRemoteUserOnLineNotify(String uid, int elapsed){
super.onRemoteUserOnLineNotify(uid, elapsed);
}
//在onRemoteUserOffLineNotify回调中解除远端视频流渲染控件的设置
@Override
public void onRemoteUserOffLineNotify(String uid, AliRtcEngine.AliRtcUserOfflineReason reason){
super.onRemoteUserOffLineNotify(uid, reason);
}
//在onRemoteTrackAvailableNotify回调中设置远端视频流渲染控件
@Override
public void onRemoteTrackAvailableNotify(String uid, AliRtcEngine.AliRtcAudioTrack audioTrack, AliRtcEngine.AliRtcVideoTrack videoTrack){
handler.post(new Runnable() {
@Override
public void run() {
if(videoTrack == AliRtcVideoTrackCamera) {
SurfaceView surfaceView = mAliRtcEngine.createRenderSurfaceView(VideoChatActivity.this);
surfaceView.setZOrderMediaOverlay(true);
FrameLayout view = getAvailableView();
if (view == null) {
return;
}
remoteViews.put(uid, view);
view.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
AliRtcEngine.AliRtcVideoCanvas remoteVideoCanvas = new AliRtcEngine.AliRtcVideoCanvas();
remoteVideoCanvas.view = surfaceView;
mAliRtcEngine.setRemoteViewConfig(remoteVideoCanvas, uid, AliRtcVideoTrackCamera);
} else if(videoTrack == AliRtcVideoTrackNo) {
if(remoteViews.containsKey(uid)) {
ViewGroup view = remoteViews.get(uid);
if(view != null) {
view.removeAllViews();
remoteViews.remove(uid);
mAliRtcEngine.setRemoteViewConfig(null, uid, AliRtcVideoTrackCamera);
}
}
}
}
});
}
/* 业务可能会触发同一个UserID的不同设备抢占的情况,所以这个地方也需要处理 */
@Override
public void onBye(int code){
handler.post(new Runnable() {
@Override
public void run() {
String msg = "onBye code:" + code;
}
});
}
};
mAliRtcEngine.setRtcEngineEventListener(mRtcEngineEventListener);
mAliRtcEngine.setRtcEngineNotify(mRtcEngineNotify);
//本地预览
mLocalVideoCanvas = new AliRtcEngine.AliRtcVideoCanvas();
SurfaceView localSurfaceView = mAliRtcEngine.createRenderSurfaceView(VideoChatActivity.this);
localSurfaceView.setZOrderOnTop(true);
localSurfaceView.setZOrderMediaOverlay(true);
FrameLayout fl_local = findViewById(R.id.fl_local);
fl_local.addView(localSurfaceView, layoutParams);
mLocalVideoCanvas.view = localSurfaceView;
mAliRtcEngine.setLocalViewConfig(mLocalVideoCanvas, AliRtcVideoTrackCamera);
mAliRtcEngine.startPreview();
//加入RTC房间
mAliRtcEngine.joinChannel(token, null, null, null);
iOS
使用ARTC SDK 加入RTC房间及推流详细步骤可参考:实现步骤
// 导入ARTC相关类
import AliVCSDK_ARTC
private var rtcEngine: AliRtcEngine? = nil
// 创建引擎并设置回调
let engine = AliRtcEngine.sharedInstance(self, extras:nil)
...
self.rtcEngine = engine
// 设置频道模式
engine.setChannelProfile(AliRtcChannelProfile.interactivelive)
engine.setClientRole(AliRtcClientRole.roleInteractive)
engine.setAudioProfile(AliRtcAudioProfile.engineHighQualityMode, audio_scene: AliRtcAudioScenario.sceneMusicMode)
//设置视频编码参数
let config = AliRtcVideoEncoderConfiguration()
config.dimensions = CGSize(width: 720, height: 1280)
config.frameRate = 20
config.bitrate = 1200
config.keyFrameInterval = 2000
config.orientationMode = AliRtcVideoEncoderOrientationMode.adaptive
engine.setVideoEncoderConfiguration(config)
engine.setCapturePipelineScaleMode(.post)
engine.publishLocalVideoStream(true)
engine.publishLocalAudioStream(true)
engine.setDefaultSubscribeAllRemoteAudioStreams(true)
engine.subscribeAllRemoteAudioStreams(true)
engine.setDefaultSubscribeAllRemoteVideoStreams(true)
engine.subscribeAllRemoteVideoStreams(true)
//设置相关回调
extension VideoCallMainVC: AliRtcEngineDelegate {
func onJoinChannelResult(_ result: Int32, channel: String, elapsed: Int32) {
"onJoinChannelResult1 result: \(result)".printLog()
}
func onJoinChannelResult(_ result: Int32, channel: String, userId: String, elapsed: Int32) {
"onJoinChannelResult2 result: \(result)".printLog()
}
func onRemoteUser(onLineNotify uid: String, elapsed: Int32) {
// 远端用户的上线
"onRemoteUserOlineNotify uid: \(uid)".printLog()
}
func onRemoteUserOffLineNotify(_ uid: String, offlineReason reason: AliRtcUserOfflineReason) {
// 远端用户的下线
"onRemoteUserOffLineNotify uid: \(uid) reason: \(reason)".printLog()
}
func onRemoteTrackAvailableNotify(_ uid: String, audioTrack: AliRtcAudioTrack, videoTrack: AliRtcVideoTrack) {
"onRemoteTrackAvailableNotify uid: \(uid) audioTrack: \(audioTrack) videoTrack: \(videoTrack)".printLog()
}
func onAuthInfoWillExpire() {
"onAuthInfoWillExpire".printLog()
/* TODO: 务必处理;Token即将过期,需要业务触发重新获取当前channel,user的鉴权信息,然后设置refreshAuthInfo即可 */
}
func onAuthInfoExpired() {
"onAuthInfoExpired".printLog()
/* TODO: 务必处理;提示Token失效,并执行离会与释放引擎 */
}
func onBye(_ code: Int32) {
"onBye code: \(code)".printLog()
/* TODO: 务必处理;业务可能会触发同一个UserID的不同设备抢占的情况 */
}
func onLocalDeviceException(_ deviceType: AliRtcLocalDeviceType, exceptionType: AliRtcLocalDeviceExceptionType, message msg: String?) {
"onLocalDeviceException deviceType: \(deviceType) exceptionType: \(exceptionType)".printLog()
/* TODO: 务必处理;建议业务提示设备错误,此时SDK内部已经尝试了各种恢复策略已经无法继续使用时才会上报 */
}
func onConnectionStatusChange(_ status: AliRtcConnectionStatus, reason: AliRtcConnectionStatusChangeReason) {
"onConnectionStatusChange status: \(status) reason: \(reason)".printLog()
if status == .failed {
/* TODO: 务必处理;建议业务提示用户,此时SDK内部已经尝试了各种恢复策略已经无法继续使用时才会上报 */
}
else {
/* TODO: 可选处理;增加业务代码,一般用于数据统计、UI变化 */
}
}
}
//本地预览
let videoView = self.createVideoView(uid: self.userId)
let canvas = AliVideoCanvas()
canvas.view = videoView.canvasView
canvas.renderMode = .auto
canvas.mirrorMode = .onlyFrontCameraPreviewEnabled
canvas.rotationMode = ._0
self.rtcEngine?.setLocalViewConfig(canvas, for: AliRtcVideoTrack.camera)
self.rtcEngine?.startPreview()
//加入RTC房间
let ret = self.rtcEngine?.joinChannel(joinToken, channelId: nil, userId: nil, name: nil) { [weak self] errCode, channelId, userId, elapsed in
if errCode == 0 {
// success
}
else {
// failed
}
let resultMsg = "\(msg) \n CallbackErrorCode: \(errCode)"
resultMsg.printLog()
UIAlertController.showAlertWithMainThread(msg: resultMsg, vc: self!)
}
let resultMsg = "\(msg) \n ReturnErrorCode: \(ret ?? 0)"
resultMsg.printLog()
if ret != 0 {
UIAlertController.showAlertWithMainThread(msg: resultMsg, vc: self)
}
2. 客户业务服务发起转推任务,将RTC房间流转推到CDN
客户业务Server创建订阅RTC房间消息的回调,监听房间内主播推流事件,订阅RTC房间消息的回调详细接口参考:CreateEventSub - 创建订阅房间消息回调。
当收到RTC房间内主播推流后,调用旁路转推OpenAPI接口StartLiveMPUTask,将RTC房间内的流转推到CDN上,旁路转推接口详情参考:StartLiveMPUTask - 创建混流转推任务(新)。
说明主播开播时可以将
MixMode
设置成0,表示单路转推不转码。接口需要传入直播推流地址,仅支持 RTMP协议,生成规则请参见生成推流地址和播放地址。客户业务Server监听CDN推流回调,当流转推到CDN后,下发直播拉流地址,通知普通观众端播放。CDN 推流回调详情参考:回调设置。
3. 普通观众端使用阿里云播放器SDK拉流播放
当普通观众端接收到业务服务拉流通知后,创建阿里云播放器实例,使用直播拉流地址进行播放。详细的播放器接口及使用请参考:播放器SDK。
建议将普通观众的CDN播放地址由RTMP格式改为HTTP-FLV格式。两者内容一致,但传输协议不同。HTTP作为互联网主流协议,具备更成熟的网络优化基础,且默认使用80/443端口,更易通过防火墙;而RTMP协议较旧,常用端口1935可能被限制,影响播放稳定性。综合来看,HTTP-FLV在通用性和播放体验(如卡顿、延迟)上优于RTMP,推荐优先使用。
Android
AliPlayer aliPlayer = AliPlayerFactory.createAliPlayer(context);
aliPlayer.setAutoPlay(true);
UrlSource urlSource = new UrlSource();
urlSource.setUri("http://test.alivecdn.com/live/streamId.flv?auth_key=XXX"); //普通观众(非连麦观众)的CDN播放地址
aliPlayer.setDataSource(urlSource);
aliPlayer.prepare();
iOS
self.cdnPlayer = [[AliPlayer alloc] init];
self.cdnPlayer.delegate = self;
self.cdnPlayer.autoPlay = YES;
AVPUrlSource *source = [[AVPUrlSource alloc] urlWithString:@""http://test.alivecdn.com/live/streamId.flv?auth_key=XXX"];
[self.cdnPlayer setUrlSource:source];
[self.cdnPlayer prepare];
步骤二 主播跨房间PK
主播A和主播B跨房间PK的基本流程:
1.主播A和主播B开始跨房间拉流
主播A和主播B分别调用跨房间拉流接口,传入目标的房间ID和用户ID,开始跨房间拉流。
Android
mAliRtcEngine.subscribeRemoteDestChannelStream(channelId, userId, AliRtcVideoTrackCamera, AliRtcAudioTrackMic, true);
iOS
[self.rtcEngine subscribeRemoteDestChannelStream:channelId uid:userId videoTrack:AliRtcVideoTrackCamera audioTrack:AliRtcAudioTrackMic sub:YES];
2.分别设置主播A和主播B渲染画面
Android
在初始化引擎的时候设置对应回调mAliRtcEngine.setRtcEngineNotify
,需要在onRemoteTrackAvailableNotify
回调中,为远端用户设置远端视图,示例代码如下:
@Override
public void onRemoteTrackAvailableNotify(String uid, AliRtcEngine.AliRtcAudioTrack audioTrack, AliRtcEngine.AliRtcVideoTrack videoTrack){
handler.post(new Runnable() {
@Override
public void run() {
if(videoTrack == AliRtcVideoTrackCamera) {
SurfaceView surfaceView = mAliRtcEngine.createRenderSurfaceView(VideoChatActivity.this);
surfaceView.setZOrderMediaOverlay(true);
FrameLayout fl_remote = findViewById(R.id.fl_remote);
if (fl_remote == null) {
return;
}
fl_remote.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
AliRtcEngine.AliRtcVideoCanvas remoteVideoCanvas = new AliRtcEngine.AliRtcVideoCanvas();
remoteVideoCanvas.view = surfaceView;
mAliRtcEngine.setRemoteViewConfig(remoteVideoCanvas, uid, AliRtcVideoTrackCamera);
} else if(videoTrack == AliRtcVideoTrackNo) {
FrameLayout fl_remote = findViewById(R.id.fl_remote);
fl_remote.removeAllViews();
mAliRtcEngine.setRemoteViewConfig(null, uid, AliRtcVideoTrackCamera);
}
}
});
}
iOS
远端用户并进行推流或停止推流时,会触发onRemoteTrackAvailableNotify
回调,在回调会设置或移除远端视图,示例代码如下:
func onRemoteTrackAvailableNotify(_ uid: String, audioTrack: AliRtcAudioTrack, videoTrack: AliRtcVideoTrack) {
"onRemoteTrackAvailableNotify uid: \(uid) audioTrack: \(audioTrack) videoTrack: \(videoTrack)".printLog()
// 远端用户的流状态
if audioTrack != .no {
let videoView = self.videoViewList.first { $0.uidLabel.text == uid }
if videoView == nil {
_ = self.createVideoView(uid: uid)
}
}
if videoTrack != .no {
var videoView = self.videoViewList.first { $0.uidLabel.text == uid }
if videoView == nil {
videoView = self.createVideoView(uid: uid)
}
let canvas = AliVideoCanvas()
canvas.view = videoView!.canvasView
canvas.renderMode = .auto
canvas.mirrorMode = .onlyFrontCameraPreviewEnabled
canvas.rotationMode = ._0
self.rtcEngine?.setRemoteViewConfig(canvas, uid: uid, for: AliRtcVideoTrack.camera)
}
else {
self.rtcEngine?.setRemoteViewConfig(nil, uid: uid, for: AliRtcVideoTrack.camera)
}
if audioTrack == .no && videoTrack == .no {
self.removeVideoView(uid: uid)
self.rtcEngine?.setRemoteViewConfig(nil, uid: uid, for: AliRtcVideoTrack.camera)
}
}
3.业务服务更新旁路转推到混流转推
业务服务收到业务App通知过来的开始主播跨房间PK的事件后,分别调用UpdateLiveMPUTask - 更新混流转推任务(新)更新直播间A和直播间B的转推任务,在UserInfos
字段传入分别传入主播A和主播B的房间号及用户ID,Layout
字段传入混流布局以及其他必要字段,更新混流。
步骤三 结束PK
主播A和主播B结束跨房间PK的基本流程:
1.主播A和主播B停止跨房间拉流
主播A和主播B分别调用跨房间拉流接口,传入目标的房间ID和用户ID,停止跨房间拉流。
Android
mAliRtcEngine.subscribeRemoteDestChannelStream(channelId, userId, AliRtcVideoTrackCamera, AliRtcAudioTrackMic, false);
iOS
[self.rtcEngine subscribeRemoteDestChannelStream:channelId uid:userId videoTrack:AliRtcVideoTrackCamera audioTrack:AliRtcAudioTrackMic sub:NO];
2.业务服务更新旁路转推到旁路流转推
业务服务收到业务App通知过来的结束主播跨房间PK的事件后,分别调用UpdateLiveMPUTask更新直播间A和直播间B的混流转推任务,MixMode设置成0及必要字段,将混流任务更新成旁路转推任务,其UpdateLiveMPUTask接口参数详见:UpdateLiveMPUTask - 更新混流转推任务(新)