跳到主要内容
版本:3.4.4

远程回放示例(FLV/HLS)

本示例以html+js构造一个简单的远程录像回放页面示例,演示远程录像回放应用的API调用过程。 阅读本文的同时,可结合查看示例页面:replay-example-2(请使用Chrome/Firefox/Edge等浏览器打开), 及其代码:replay-example-2.zip进行本文的阅读。

除了实现远程录像回放功能以外,本示例还提供一下媒体播放器的抽象,供参考。

本示例主要使用到以下库:

基本流程

replay-sequence.png

  1. 首先,终端要连接到 Micro-GNSS 服务
  2. 调用登录 POST /login 接口,获得会话 token
  3. 建立 Websocket 连接,并订阅 /user/{token}/queue/strm 队列中的消息
  4. 调用 打开媒体播放 POST /strm/replay/open 接口
  5. 等待 Websocket 的媒体可用通知
  6. 得到媒体可用通知后,用媒体播放器打开接口返回的媒体地址,即开始播放
  7. 在媒体播放期间,建立一个定时器,每隔 15 秒调用一次媒体保持 POST /strm/keep_alive 接口
  8. 要关闭播放时

以下分节详述各个步骤:

连接终端

准备一台符合JT/T 1076标准的终端,记下终端的通讯标识号(即协议中所说的终端手机号,以下假设为8888000001)。将终端的服务器地址设为:n11.gratour.info,端口号设为: 7012。 如果终端设置了ACC ON才响应音视频指令的话,则终端应接上ACC ON信号线。

等待终端上线

启动终端后,接下来我们就是等待终端上线。可到 https://wx.gratour.info:3001/ 查看是否已经上线。如下图示,我们在在线终端列表中看到终端8888000001,表示该终端已经上线。

term-online.png

调用登录接口

终端上线后,我们就可以通过API来对终端进行操作。本示例使用测试服务器的API( https://n11.gratour.info:7011/ )来进行操作。 API操作的第一步,就是用用户名/密码(admin/admin)调用POST /login接口进行登录(参见示例代码中apiLogin()函数),如下:

POST https://n11.gratour.info:7011/v1/login
Content-Type: application/json

{
"userName":"admin",
"password": "admin"
}

得到应答:

{
"data": [
{
"authToken": "w3-73jH2TWq4cmYKGuCWMw",
"ver": "3.4.0"
}
],
"count": 1,
"errCode": 0,
"message": "成功。"
}

应答中的data[0].authToken为登录令牌,后续的调用中均需要带上此令牌。

订阅WebSocket通知

WebSocket接口地址

WebSocket地址可从HTTP接口地址中派生,只需将HTTP接口地址的https变为wss或将http变为ws,然后在后面加上/ws__token参数即可。如API地址为:

https://n11.gratour.info:7011/v1

则WebSocket接口地址为:

wss://n11.gratour.info:7011/v1/ws?__token=Ge4E1xNHSfW8NYa0VJe48A

上面地址示例中的__token参数的参数值为POST /login接口返回的authToken属性的值。

见示例代码中的connectWs()函数:

    /**
* 连接到websocket并监听 StrmNotif 事件
*/
function connectWs() {
// 从HTTP API地址中派生 websocket 接口地址
let url = apiUrlPrefix();
url = url.replace('https://', 'wss://');
if (!url.endsWith('/v1/'))
url += 'v1/';
url += 'ws?__token=' + token; // 用 `POST /login`接口返回的`authToken`作为`__token`参数
stomp = Stomp.client(url);

// ...
}

有关WebSocket的基本约定,请参考:基本约定

连接WebSocket的时机和重连

一旦登录成功,我们就可以连接WebSocket并订阅需要监听的事件:

        infoLog('登录到:' + apiUrlPrefix());
axios.post(apiUrlPrefix() + 'login', loginReq)
.then(function (resp) {
token = apiCheck(resp).data[0].authToken;

// 更新最后成功调用API的时间
lastApiTime = new Date().getTime();

// 连接websocket进行事件监听
connectWs();

// ...
})
.catch(function (err) {
// ...
});

如果WebSocket因网络原因断开时,WebSocket客户端的错误处理函数将被调用,我们可以在这个处理函数中进行一个延时的重连处理。 但在重连处理中有一点需要注意的是,WebSocket会话的有效性依赖于token的有效性。当token失效时(如长时间无HTTP API调用),WebSocket会话也会失效,WebSocket客户端也会发生错误,从而触发错误处理函数被调用。 因此,在WebSocket客户端的错误处理函数中,要先确定token的有效性,只在认为token仍有效的情况下才尝试重连。

由于token的失效主要原因是长时间无HTTP API调用,所以为了确定token有效性,可以引入一个记录最后一次成功调用HTTP API的时间的变量,每次成功调用API后,将变量设为当前时间;判断时,如果当前时间距离上次成功调用API时间超过10分钟(此时间值在服务端可配置),则认为token已经无效。

示例代码中的lastApiTime即为记录最后一次成功调用API的时间变量:

    /**
* 连接到websocket并监听 StrmNotif 事件
*/
function connectWs() {
// ...
stomp.connect(
// 连接参数,未使用
{},

// 连接成功回调
function (_) {
// ...
},

// 错误回调
function (err) {
const message = 'Websocket发生错误:' + err.toString();
errLog(message);
disconnectWs();
if (token) {
const sinceLastApiCall = new Date().getTime() - lastApiTime;
if (sinceLastApiCall < 10 * 60 * 1000) {
// 如果上次成功调用API是在10分钟前,则token依然是有效的,此种情况下我们重连websocket(5秒后)
setTimeout(() => connectWs(), 5000);
}
}
}
);
}

订阅流媒体状态通知

WebSocket通知的订阅地址不是固定地址,其中一个包含token占位符,实际使用时替换为POST /login接口返回的authToken的值。例如,流媒体状态通知的订阅地址模式为:

/user/{token}/queue/strm

假设登录后得到的authTokenGe4E1xNHSfW8NYa0VJe48A,那么订阅地址为:

/user/Ge4E1xNHSfW8NYa0VJe48A/queue/strm

代码中的相关部分为:

    /**
* 连接到websocket并监听 StrmNotif 事件
*/
function connectWs() {
// 从HTTP API地址中派生 websocket 接口地址
let url = apiUrlPrefix();
url = url.replace('https://', 'wss://');
if (!url.endsWith('/v1/'))
url += 'v1/';
url += 'ws?__token=' + token; // 用 `POST /login`接口返回的`authToken`作为`__token`参数
stomp = Stomp.client(url);

stomp.connect(
// 连接参数,未使用
{},

// 连接成功回调
function (_) {
stompConnected = true;

// 队列名称构成方式:/user/{token}/queue/strm
const queueName = '/user/' + token + '/queue/strm';

// 订阅 StrmNotif 事件
stomp.subscribe(queueName, (frame) => {
// 事件处理
// ...
});
},

// 错误回调
function (err) {
// ...
}
);
}

查询并列出终端上的录像文件

由用户指定查询条件(响应btnQryAv按钮点击事件),查询并列出终端上的远程录像文件:

        axios.get(apiUrlPrefix() + "strm/stored", {
params: {
simNo: sn,
startTime: startTime(),
endTime: endTime(),
channel: channelId(),
mediaType: mediaType(),
codeStream: codeStream(),
storageType: 0
},
headers: {
'X-Auth-Token': token
}
}).then(function (resp) {
let data = apiCheck(resp).data;

// ...

// 根据返回结果填充表格内容
data.forEach(item => {
// ...
});
});

调用打开远程录像接口

当用户选择要播放的录像文件(点击文件记录相应的播放按钮)后,以选择的文件信息作为参数调用POST /strm/replay/open接口(参见示例代码中的apiReplayOpen()函数),打开远程录像,如下:

        clientProto = proto();        
axios.post(apiUrlPrefix() + "strm/replay/open", {
simNo: item.simNo,
channel: item.channel,
mediaType: item.mediaType,
codeStream: item.codeStream,
storageType: item.storageType,
mode: GnssApi.OPEN_REPLAY_MODE__NORMAL,
factor: 0, // not used here
startTime: item.startTime,
endTime: item.endTime,
proto: clientProto,
async: true
}, {
headers: {
'X-Auth-Token': token
}
}).then(function (resp) {
/**
* @type {{reqId: string, playUrl: string, mediaTyp: string}}
*/
let data = apiCheck(resp).data[0];

// 得到请求ID(reqId)后,保存起来,后续关闭通道时要用到
reqId = data.reqId;

setStateText('流已经创建');

// 启动保持定时器,间隔:15秒
keepAliveTimer = setInterval(apiKeepAlive, 15000);

// `ready`属性指出流是否可用。也可在当`ready`为`true`时才创建播放器并加载码流。
// 如果不为`true`,在收到 `strmReady` 事件后才创建播放器并加载码流。见前connectWs()中的 `strmReady` 事件处理
if (data.ready)
createPlayerAndLoad(data.playUrl, data.mediaTyp);

setButtonEnabled(btnStop, true);
}).catch(function (err) {
alert(err);
});

注意请求中带上了X-Auth-Token头部。

实际发出的请求示例:

POST https://n11.gratour.info:7011/v1/strm/replay/open
Content-Type: application/json
X-Auth-Token: w3-73jH2TWq4cmYKGuCWMw

{
"simNo": "8888000001",
"channel": 1,
"mediaType": 0,
"codeStream": 1,
"storageType": 1,
"mode": 0,
"factor": 0,
"startTime": "2020-03-01 11:08:47",
"endTime": "2020-03-01 11:16:18",
"proto": 0,
"async": true
}

调用后,服务端将向终端下发远程录像回放请求(9201)指令,并返回播放地址,类似于:

{
"data": [
{
"reqId": "AbctuB9sSJe8bbBzv-yr9g",
"instanceId": "0.default",
"ctrl": true,
"playUrl": "wss://n11.gratour.info:7026/w/8888000001_1_1?a\u003dCWRhomoI\u0026b\u003d2bSZUPSTLNGbehxePaOWsiQhBr3lDQfKwchHv4wxNL4MptjvPYGNRLVRbxuLbfBaA_yjYYBRRbh6uHCmiVO6a9TD6YPJ3Rsv2J9",
"mediaTyp": "av",
"ready": false
}
],
"count": 1,
"errCode": 0,
"message": "OK."
}

以上应答返回的data[0]中,有几个我们需要注意的属性:

  • reqId:请求ID,表示服务分配给本次音视频请求的ID,此ID在后面调用POST /strm/keep_alivePOST /strm/live/ctrl接口时需要用到
  • playUrl:播放地址。我们可以直接用播放器打开此地址进行播放。
  • mediaTyp:媒体码流的音视频属性,有三个可能值:
    • av:表示码流包含音频和视频
    • a:表示码流仅包含音频
    • v:表示码流仅包含视频
  • ready: 流是否已经准备好,通常终端通道尚未打开,此时,ready的值为false,我们需要等待Websocket的actready的媒体通知,才开始播放媒体。 但如果已经有其他用户打开了终端通道,则ready的值为truereadytrue则我们可以直接打开playUrl所指向的媒体流。

启动保持定时器

在前面的POST /strm/replay/open请求成功返回后,客户端应立即启动一个间隔为15秒的保持定时器,每当定时器到时时,用请求ID(reqId)调用POST /strm/keep_alive,以保持通道的活跃。


/**
* 打开实时音视频
*/
function apiReplayOpen() {

// ......

axios.post(apiUrlPrefix() + "strm/replay/open", {
simNo: item.simNo,
channel: item.channel,
mediaType: item.mediaType,
codeStream: item.codeStream,
storageType: item.storageType,
mode: GnssApi.OPEN_REPLAY_MODE__NORMAL,
factor: 0, // not used here
startTime: item.startTime,
endTime: item.endTime,
proto: clientProto,
async: true
}, {
headers: {
'X-Auth-Token': token
}
}).then(function (resp) {

// ......

// 启动保持定时器,间隔:15秒 <-----
keepAliveTimer = setInterval(apiKeepAlive, 15000);

// ......
}).catch(function (err) {
alert(err);
});
}


/**
* 流请求保持
*/
function apiKeepAlive() {
if (!token)
return;

axios.post(apiUrlPrefix() + "strm/keep_alive", {reqIds: [reqId]}, {
headers: {
'X-Auth-Token': token
}
})
.then((resp) => {
if (resp.data.errCode !== 0) {
errLog('保持失败,通道已经关闭:' + resp.data.errCode + '-' + resp.data.message);
stop();
changeStateToClosed();
} else {
console.log("KeepAlive sent");
}
})
.catch((err) => {
errLog('调用保持接口时遇到错误:' + err.toString());
})
}

注意:如果没有及时调用POST /strm/keep_alive接口,通道可能被服务端判断为没有使用者而关闭。

播放媒体

POST /strm/replay/open接口返回后,如果 data[0].ready属性的有两种情况:

  • data[0].readytrue时,我们直接创建播放器并加载媒体
    /**
* 打开实时音视频
*/
function apiReplayOpen() {

// ......

axios.post(apiUrlPrefix() + "strm/replay/open", {
simNo: item.simNo,
channel: item.channel,
mediaType: item.mediaType,
codeStream: item.codeStream,
storageType: item.storageType,
mode: GnssApi.OPEN_REPLAY_MODE__NORMAL,
factor: 0, // not used here
startTime: item.startTime,
endTime: item.endTime,
proto: clientProto,
async: true
}, {
headers: {
'X-Auth-Token': token
}
}).then(function (resp) {
/**
* @type {{reqId: string, playUrl: string, mediaTyp: string}}
*/
let data = apiCheck(resp).data[0];

// ......

// `ready`属性指出流是否可用。也可在当`ready`为`true`时才创建播放器并加载码流。
// 如果不为`true`,在收到 `strmReady` 事件后才创建播放器并加载码流。见前connectWs()中的 `strmReady` 事件处理
if (data.ready)
createPlayerAndLoad(data.playUrl, data.mediaTyp);

// ......
}).catch(function (err) {
alert(err);
});
}
  • 否则,我们要等到接收到actready的Websocket媒体通知才创建播放器并加载:
    /**
* 连接到websocket并监听 StrmNotif 事件
*/
function connectWs() {
// ...

stomp.connect(
// 连接参数,未使用
{},

// 连接成功回调
function (_) {
// ...

// 订阅 StrmNotif 事件
stomp.subscribe(queueName, (frame) => {
// 事件处理

/**
* @type StrmNotif
*/
const notif = JSON.parse(frame.body);

// ...

// 判断事件类型并作相应的处理
switch (notif.act) {
case StrmNotif.ACT__strmReady:
infoLog('媒体流已经可用');
if (flvPlayer == null) {
// 如果尚未创建 flvPlayer,则创建播放器并加载码流
createPlayerAndLoad(notif.playUrl, notif.mediaTyp);
}
break;
}
});
},

// 错误回调
function (err) {
// ...
}
);
}

在本示例中,我们将 flvjs/hlsjs和原生播放器 进行抽象,得出一个最简的 PlayerWrapper (在player.js中定义):

/**
* 播放器抽象
*/
class PlayerWrapper {

/**
*
* @param {PlayerContainer} container PlayerContainer对象
* @param {HTMLVideoElement} videoElmt 媒体播放元素
* @param {string} mediaTyp 媒体类型:av - 音视频; a - 仅音频; v - 仅视频
* @param {string} playUrl 媒体播放地址
*/
constructor(container, videoElmt, mediaTyp, playUrl) {
}

/**
* 分辨率信息是否已经准备好
*
* @return {boolean}
*/
get resolutionInfoReady() {
}

/**
* 播放器是否处于静音状态
*
* @return {boolean}
*/
get muted() {
}

/**
* 设置播放器静音
*
* @param value 是否要设置为静音
*/
set muted(value) {
}

/**
* 加载媒体流并打开
*/
load() {
}

/**
* 暂停播放
*/
pause() {
}

/**
* 停止播放
*/
stop() {
}
}

同时 player.js中还实现了FlvPlayerWrapperHlsNativePlayerHlsJsPlayerWrapper三个播放器。要使用这些播放器,需要提供一个 PlayerContainer 类的实现。

PlayerContainer是一个抽象类,声明如下:

/**
* 播放器容器抽象,使用FlvPlayerWrapper/HlsNativePlayer/HlsJsPlayerWrapper等播放器时,要提供一个此抽象的实现类
*/
class PlayerContainer {


/**
* 播放器的ID,如一个页面内有多个时,用此ID区分。主要用于调试。
*
* @return {string}
*/
id() {
}

/**
* 是否正在请求打开流媒体或已经打开流媒体
*/
isOpenRequested() {

}

/**
* 当 PlayerWrapper 发生错误时,此方法被 PlayerWrapper 调用。实现类通常在此方法中在UI上提示用户。
*
* @param {string} errorMessage 错误信息
*/
onPlayError(errorMessage) {
}

/**
* 当开始播放时,此方法被 PlayerWrapper 调用。
*/
onPlaying() {
}

/**
* 当播放正常或异常结束时,此方法 PlayerWrapper 调用。实现类通常在此方法中做一些清理工作。
*/
onClose() {
}

}

在示例中,我们实现 PlayerContainer 类如下:

class PlayerContainerImpl extends PlayerContainer {

/**
* 播放器的ID,如一个页面内有多个时,用此ID区分。主要用于调试。
*
* @return {string}
*/
id() {
return 'default';
}


/**
* 是否正在请求打开流媒体或已经打开流媒体
*/
isOpenRequested() {
return openRequested;
}

/**
* 当 PlayerWrapper 发生错误时,此方法被 PlayerWrapper 调用。实现类通常在此方法中在UI上提示用户。
*
* @param {string} errorMessage 错误信息
*/
onPlayError(errorMessage) {
errLog(errorMessage);
}

/**
* 当开始播放时,此方法被 PlayerWrapper 调用。
*/
onPlaying() {
infoLog('正常回放');
}

/**
* 当播放正常或异常结束时,此方法 PlayerWrapper 调用。实现类通常在此方法中做一些清理工作。
*/
onClose() {
infoLog('播放器已经关闭');
}
}

而创建播放器并加载媒体流的函数createPlayerAndLoad则根据所选择的客户端协议来创建对应的播放器,如下:

const playerContainer = new PlayerContainerImpl();

/**
* 创建播放器并加载码流
*
* @param {string} url
* @param {string} mediaTyp
* @param {number} proto 客户端协议(流媒体格式)。0: FLV; 1: HLS
*/
function createPlayerAndLoad(url, mediaTyp, proto) {
playingUrl = url;

const videoElement = document.getElementById('vid');

switch (proto) {
case GnssApi.PROTO__FLV:
FlvPlayerWrapper.checkSupported();
player = new FlvPlayerWrapper(playerContainer, videoElement, mediaTyp, playingUrl);
break;

case GnssApi.PROTO__HLS:
if (hlsNativeSupport) {
HlsNativePlayer.checkSupported();
player = new HlsNativePlayer(playerContainer, videoElement, mediaTyp, playingUrl);
} else {
HlsJsPlayerWrapper.checkSupported();
player = new HlsJsPlayerWrapper(playerContainer, videoElement, mediaTyp, playingUrl);
}
break;

default:
throw new Error("不支持的客户端协议(流媒体格式)");
}
player.load();

setStateText('加载中...');
infoLog('加载:' + playingUrl);
}

结束播放

结束播放时,可先关闭播放器,停止调用保持接口,最后调用关闭媒体请求接口POST /strm/close,这样整个播放过程就结束了。

    /**
* 流媒体控制:停止
*/
function apiStop() {
if (!reqId)
return;

const rid = reqId;
reqId = null;

infoLog('关闭通道:POST /strm/close');
axios.post(apiUrlPrefix() + 'strm/close', {
reqIds: [rid]
}, {
headers: {
'X-Auth-Token': token
}
}).then(function (resp) {
const r = resp.data;
if (r.errCode !== 0) {
errLog('关闭通道时遇到错误:' + r.message);
alert(r.message);
return;
}

infoLog('关闭通道成功');
}).catch(function (err) {
errLog('关闭通道时遇到错误:' + err.toString());
alert(err);
});
}


/**
* 停止播放
*/
function stop() {
// 关闭 keep-alive 定时器
cancelTimer();

// 关闭、销毁播放器
stopPlayer();

// 调用控制接口,关闭通道
apiStop();

changeStateToClosed();
}

如果没有调用POST /strm/close来关闭通道,服务端将在保持超时后关闭通道。