播放音视频
这页讲的是:在一条已经建立的连接上,设备端如何开始发送音视频,客户端如何按约定的 stream_id 接收并播放。
如果你还没完成建连,先看建立连接。
先准备这几项
- 设备端已经启动,并且已经在
on_conn_accepted(hconn)里拿到这次连接的hconn - 客户端已经持有这次播放要使用的
conn连接对象 - 双端已经约定好音频流和视频流使用的
stream_id
什么是 stream_id
stream_id 用来标识同一条连接里的某一条 stream。
你可以把它理解成“这条连接里第几路音频、视频或流内消息”的编号。比如你们约定 10 表示主音频、11 表示主视频,那么设备端发送 stream_id = 11 的视频帧时,客户端就应该把 streamId = 11 绑定到对应的视频输出对象。
这里先只看已经明确的公开约束:
- 取值范围是
0到15。 - 一条连接上可以同时有多条独立音视频流,用
stream_id区分。 - 在同一条连接里,音频和视频不能共用同一个
stream_id。
stream_id 只回答“这是哪一条 stream”;音频还是视频,看 media。
双端只需要先约定:哪个编号给音频,哪个编号给视频。只要一致,而且不冲突,就可以工作。
什么时候开始发送
连接建立,不等于媒体已经开始发送。常见做法有三种:
| 做法 | 客户端处理 | 设备端处理 |
|---|---|---|
| 连上就发 | 只要绑定好 output 并建连成功,就直接接收 | 在 on_conn_accepted(hconn) 后直接开始发送 |
| 收到订阅再发 | 连接进入 CONNECTED 后调用 subscribeVideo(streamId) / subscribeAudio(streamId);停止前再调用对应 unsubscribe*() | 在 on_subscribe_video / on_subscribe_audio 里开始,在 on_unsubscribe_* 里停止 |
| 业务自己决定 | 由你的业务决定何时订阅、退订或请求关键帧 | 由你的业务状态机决定何时开始、何时停止 |
不管你选哪种做法,都要满足这些要求:
- 每路视频的第一帧必须是关键帧。
- 第一帧关键帧要尽快发出去。
- 收到
on_request_key_frame后,下一帧要切成关键帧。 - 如果你按订阅发送,就要真的响应
on_subscribe_*和on_unsubscribe_*,客户端也要显式发起对应的订阅或退订。
设备端:把音视频送上这条连接
设备端通常会为每条连接维护一份上下文:回调里改状态,工作线程里真正编码和发送。
#include <stdint.h>
#include <string.h>
enum {
kAudioStreamId = 10,
kVideoStreamId = 11,
};
typedef struct {
tirtc_conn_t hconn;
volatile int audio_enabled;
volatile int video_enabled;
volatile int force_video_key_frame;
} ConnCtx;
static int send_h264_frame(
tirtc_conn_t hconn,
const void *data,
uint32_t len,
uint32_t ts_ms,
int is_key_frame) {
TIRTCFRAMEINFO fi = {0};
fi.stream_id = kVideoStreamId;
fi.media = TIRTC_VIDEO_H264;
fi.ts = ts_ms;
fi.length = len;
if (is_key_frame) {
fi.flags |= TIRTC_FRAME_FLAG_KEY_FRAME;
}
return TiRtcSendVideoStream(hconn, &fi, data);
}
static int send_g711a_frame(
tirtc_conn_t hconn,
const void *data,
uint32_t len,
uint32_t ts_ms) {
TIRTCFRAMEINFO fi = {0};
fi.stream_id = kAudioStreamId;
fi.media = TIRTC_AUDIO_ALAW;
fi.flags = TIRTC_AUDIOSAMPLE_8K16B1C;
fi.ts = ts_ms;
fi.length = len;
return TiRtcSendAudioStream(hconn, &fi, data);
}
static int on_subscribe_video(tirtc_conn_t hconn, uint8_t stream_id) {
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx != NULL && stream_id == kVideoStreamId) {
ctx->video_enabled = 1;
ctx->force_video_key_frame = 1;
}
return 0;
}
static void on_unsubscribe_video(tirtc_conn_t hconn, uint8_t stream_id) {
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx != NULL && stream_id == kVideoStreamId) {
ctx->video_enabled = 0;
}
}
static int on_subscribe_audio(tirtc_conn_t hconn, uint8_t stream_id) {
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx != NULL && stream_id == kAudioStreamId) {
ctx->audio_enabled = 1;
}
return 0;
}
static void on_unsubscribe_audio(tirtc_conn_t hconn, uint8_t stream_id) {
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx != NULL && stream_id == kAudioStreamId) {
ctx->audio_enabled = 0;
}
}
static void on_request_key_frame(tirtc_conn_t hconn, uint8_t stream_id) {
ConnCtx *ctx = (ConnCtx *)TiRtcConnGetUserData(hconn);
if (ctx != NULL && stream_id == kVideoStreamId) {
ctx->force_video_key_frame = 1;
}
}这段代码已经把发送控制面收住了:固定 stream_id、响应 subscribe / unsubscribe、响应关键帧请求。
继续把发送循环接起来时,再盯住这几件事:
- 每路视频的第一帧必须是关键帧。
- 收到
on_request_key_frame后,下一帧切成关键帧。 - 如果发送的是 JPEG,每帧天然就是关键帧。
TiRtcSendVideoStream()返回TIRTC_E_BUSY时,不要继续堆非关键帧;非关键帧可以丢,关键帧短暂重试。- 回调都在 SDK 内部线程里执行;回调里只改状态,不直接做阻塞 IO、编码或长时间计算。
- 收到
on_conn_error后,这条连接上的收发已经不再可靠,不要继续沿用旧hconn。
如果你选择“连上就发”,就在 on_conn_accepted(hconn) 后直接打开发送;如果你选择“业务自己决定”,就由你的业务代码同时控制设备端发送开关和客户端对应的订阅、退订动作。
客户端:按同一组 stream_id 接收并播放
客户端这边至少要做四件事:创建对象、绑定 streamId、发起连接、在需要时订阅媒体流。
attach(...) 只决定本地播放哪一条 stream;真正请求远端开始或停止发送的是 subscribe*() / unsubscribe*()。下面示例采用“收到订阅再发”策略;如果你的设备端是“连上就发”,可以省略订阅和退订这一步。
val conn = TiRtcConn()
val audioOutput = TiRtcAudioOutput()
val videoOutput = TiRtcVideoOutput()
conn.onStateChanged = TiRtcConnStateListener { state ->
Log.i("TiRTC", "conn state=$state")
if (state == TiRtcConnState.CONNECTED) {
conn.subscribeAudio(streamId = 10)
conn.subscribeVideo(streamId = 11)
}
}
audioOutput.onStateChanged = TiRtcAudioOutputStateListener { state ->
Log.i("TiRTC", "audio state=$state")
}
videoOutput.onStateChanged = TiRtcVideoOutputStateListener { state ->
Log.i("TiRTC", "video state=$state")
}
videoOutput.onRenderSizeChanged = TiRtcVideoOutputRenderSizeListener { size ->
Log.i("TiRTC", "video size=${size.width}x${size.height}")
}
videoOutput.attachView(videoContainer)
audioOutput.attach(conn, streamId = 10)
videoOutput.attach(conn, streamId = 11)
val code = conn.connect(remoteId = remoteId, token = token)
if (code != 0) {
Log.e("TiRTC", "connect request failed code=$code")
}
// 页面不可见时,如果远端按订阅再发,就先退订,再停播断连;页面销毁时再最终 release。
if (conn.state == TiRtcConnState.CONNECTED) {
conn.unsubscribeAudio(streamId = 10)
conn.unsubscribeVideo(streamId = 11)
}
audioOutput.detach()
videoOutput.detach()
conn.disconnect()
videoOutput.detachView()
audioOutput.release()
videoOutput.release()
conn.release()final class PlayerController: NSObject, TiRtcConnDelegate,
TiRtcAudioOutputDelegate, TiRtcVideoOutputDelegate {
lazy var conn = TiRtcConn(delegate: self)
let audioOutput = TiRtcAudioOutput()
let videoOutput = TiRtcVideoOutput()
func start(remoteId: String, token: String, videoView: UIView) {
audioOutput.delegate = self
videoOutput.delegate = self
_ = videoOutput.attachView(videoView)
_ = audioOutput.attach(to: conn, streamId: 10)
_ = videoOutput.attach(to: conn, streamId: 11)
let code = conn.connect(to: remoteId, token: token)
if code != 0 {
print("connect request failed: \(code)")
}
}
func stop() {
if conn.state == .connected {
_ = conn.unsubscribeAudio(streamId: 10)
_ = conn.unsubscribeVideo(streamId: 11)
}
audioOutput.detach()
videoOutput.detach()
conn.disconnect()
}
func dispose() {
videoOutput.detachView()
audioOutput.invalidate()
videoOutput.invalidate()
conn.invalidate()
}
func conn(_ conn: TiRtcConn, didChangeState state: TiRtcConnState) {
print("conn state=\(state)")
if state == .connected {
_ = conn.subscribeAudio(streamId: 10)
_ = conn.subscribeVideo(streamId: 11)
}
}
func audioOutput(_ output: TiRtcAudioOutput, didChangeState state: TiRtcAudioOutputState) {
print("audio state=\(state)")
}
func videoOutput(_ output: TiRtcVideoOutput, didChangeState state: TiRtcVideoOutputState) {
print("video state=\(state)")
}
func videoOutput(_ output: TiRtcVideoOutput, didChangeRenderSize size: CGSize) {
print("video size=\(size.width)x\(size.height)")
}
}final TiRtcConn conn = TiRtcConn();
final TiRtcAudioOutput audioOutput = TiRtcAudioOutput();
final TiRtcVideoOutput videoOutput = TiRtcVideoOutput();
conn.onStateChanged = (state) {
debugPrint('conn state=$state');
if (state == TiRtcConnState.connected) {
conn.subscribeAudio(streamId: 10);
conn.subscribeVideo(streamId: 11);
}
};
audioOutput.onStateChanged = (state) {
debugPrint('audio state=$state');
};
videoOutput.onStateChanged = (state) {
debugPrint('video state=$state');
};
videoOutput.onRenderSizeChanged = (size) {
debugPrint('video size=${size.width}x${size.height}');
};
audioOutput.attach(connection: conn, streamId: 10);
videoOutput.attach(connection: conn, streamId: 11);
final int code = conn.connect(remoteId: remoteId, token: token);
if (code != 0) {
debugPrint('connect request failed code=$code');
}
// 页面销毁时,如果远端按订阅再发,就先退订,再释放对象。
if (conn.state == TiRtcConnState.connected) {
conn.unsubscribeAudio(streamId: 10);
conn.unsubscribeVideo(streamId: 11);
}
videoOutput.detach();
videoOutput.dispose();
audioOutput.detach();
audioOutput.dispose();
conn.disconnect();
conn.dispose();跑通后的常见信号是:
- 连接进入
CONNECTED - 音频开始稳定输出
- 视频开始稳定播放
- 视频输出拿到渲染尺寸变化回调
客户端这边再记三点:
attach(...)只决定本地消费哪一条stream;设备端如果采用“收到订阅再发”,你还要显式调用subscribeAudio()/subscribeVideo(),退出前再调用对应的unsubscribe*()。- 如果画面刚接入、解码器刚恢复,或者画面已经花掉,可以主动调用
requestKeyFrame(streamId)请求对端补关键帧。 detach()/disconnect()不是最终清理;对象生命周期结束时,还要继续release()、invalidate()或dispose()。