rtc.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. <template>
  2. <div align="center" class="rtc-page cover-bg" :style="{height: pageData.height + 'px'}">
  3. <!-- video 容器 -->
  4. <div class="video-wrapper" id="videoRef" :style="{width: pageData.videoWidth + 'px', height: pageData.videoHeight + 'px'}"></div>
  5. <!-- 三menu键 -->
  6. <div id="foot-menu-wrap">
  7. <div @click.stop=sendKey(187)><van-icon name="wap-nav" size="1rem"/></div>
  8. <div @click.stop=sendKey(3)><van-icon name="wap-home-o" size="1rem"/></div>
  9. <div @click.stop=sendKey(4)><van-icon name="arrow-left" size="1rem"/></div>
  10. </div>
  11. </div>
  12. </template>
  13. <script>
  14. import meta from './config/meta.js';
  15. import request from './config/request.js';
  16. /**
  17. * @description: 判断当前页面运行环境
  18. * @return {Object} 返回当前页面运行环境
  19. */
  20. const isBrowserEnvironment = function() {
  21. // 判断是否在浏览器环境中
  22. const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined' && typeof navigator !== 'undefined';
  23. // 判断是否在微信环境中
  24. const isWechat = /MicroMessenger/i.test(navigator.userAgent);
  25. // 判断是否在微信小程序的 web-view 中
  26. const isMiniProgramWebview = isWechat && /miniProgram/i.test(navigator.userAgent);
  27. // 判断是否是 iPhone 设备
  28. const isIPhone = /iPhone/i.test(navigator.userAgent);
  29. // 判断是否是顶级窗口(不是嵌套在 iframe 中)目前只有ios的浏览器中才会是顶级窗口
  30. const isTopWindow = window.parent === window.self;
  31. return {
  32. isBrowser, // 是否在浏览器环境中
  33. isWechat, // 是否在微信环境中
  34. isMiniProgramWebview, // 是否在微信小程序的 web-view 中
  35. isIPhone, // 是否是 iPhone 设备
  36. isTopWindow, // 是否是顶级窗口
  37. };
  38. }
  39. export default {
  40. auth: false,
  41. name: 'webRTC',
  42. layout: 'cloudPhone',
  43. head() {
  44. return {
  45. title: '云手机',
  46. meta: [ ...meta ],
  47. script: [
  48. {
  49. // js文件指向 static 目录下的文件
  50. src: './rtcEngine/config/js/SDK.min.js', // sdk 2.0文件
  51. type: 'text/javascript',
  52. async: false,
  53. defer: false,
  54. onload: '$_script_loadHandler()', // 加载成功回调created生命周期中定义的方法
  55. onerror: '$_script_errHandler()', // 加载失败回调created生命周期中定义的方法
  56. },
  57. {
  58. // js文件指向 static 目录下的文件
  59. src: './static/js/uni.webview.1.5.2.js', // uniapp webview 1.5.2文件
  60. type: 'text/javascript',
  61. async: true,
  62. defer: false,
  63. }
  64. ]
  65. }
  66. },
  67. data() {
  68. return {
  69. // SDK加载状态
  70. sdkLoadStatus: 'loading', // sdk 加载状态 [loading|success|error]
  71. // 页面数据
  72. pageData: {
  73. width: 0, // 页面宽度
  74. height: 0, // 页面高度
  75. footMenuHeight: 0, // 底部菜单高度
  76. videoWidth: 0, // 视频宽度
  77. videoHeight: 0, // 视频高度
  78. },
  79. // 是否支持webRTC
  80. isSupportRtc: !!(
  81. typeof RTCPeerConnection !== 'undefined' &&
  82. typeof RTCIceCandidate !== 'undefined' &&
  83. typeof RTCSessionDescription !== 'undefined'
  84. ),
  85. // url 问号后的参数
  86. parametersData: {},
  87. // 卡的连接信息
  88. connectData: {},
  89. // 云手机引擎 播放器实例
  90. engine: null,
  91. }
  92. },
  93. // 页面初始化后触发
  94. async fetch() {
  95. // 预生产环境
  96. // http://192.168.211.37:3000/h5/rtcEngine/rtc/?record=2990744&userCardId=2990744&mealType=VIP&sourceType=0&userCardType=0&validTime=43200&rm=preZJHZ&authPhone=none&username=CGJkh1646034892SZX&token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyYW5kb20iOiI5MTY4OCIsImNsaWVudCI6IjciLCJ1c2VyVHlwZSI6IjIiLCJtZXJjaGFudFNpZ24iOiJTWlgiLCJleHAiOjE3NDc1MjExMTIsInVzZXJuYW1lIjoiQ0dKa2gxNjQ2MDM0ODkyU1pYIn0.zqRNm0uW79VX2KV0sv3r9OsXHFOzkIpZP_tem4-lL4M&isTips=1&isWeixin=0&merchantSign=SZX
  97. // 测试环境
  98. // http://192.168.211.37:3000/h5/rtcEngine/rtc/?record=902481&userCardId=902481&mealType=sq&sourceType=0&userCardType=0&validTime=10334&rm=tencent-ap-tianjin-1&authPhone=none&username=0EsH01666175530SZX&token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyYW5kb20iOiI0MTg5MyIsImNsaWVudCI6IjciLCJ1c2VyVHlwZSI6IjIiLCJtZXJjaGFudFNpZ24iOiJTWlgiLCJleHAiOjE3NDczODQ2MTgsInVzZXJuYW1lIjoiMEVzSDAxNjY2MTc1NTMwU1pYIn0.fTXawDu4SyEj4mIGr61ZXt0RSXzt3JztPq8-rFe-Zes&isTips=1&isWeixin=0&merchantSign=SZX
  99. // 获取页面传递参数
  100. this.parametersData = this.$route.query;
  101. // 调用接口获取卡连接数据
  102. const cardData = await this.getConnectData(this.parametersData);
  103. // 判断卡的连接方式
  104. const connectData = await this.judgeConnectType(cardData);
  105. // 保存卡连接信息
  106. this.connectData = connectData;
  107. },
  108. computed: {
  109. // 是否为微信浏览器环境
  110. isWeChatBrowser() {
  111. return this.$userAgent.isWx;
  112. }
  113. },
  114. created() {
  115. this.$toast.loading({
  116. duration: 0, // 持续展示 toast
  117. message: '数据加载中...',
  118. });
  119. // 设置html标签的背景为黑色
  120. document.body.style.background = '#000';
  121. // 定义全局变量 用于监听sdk加载状态
  122. window.$_script_loadHandler = ()=> {
  123. console.log('SDK加载成功');
  124. this.sdkLoadStatus = 'success';
  125. };
  126. window.$_script_errHandler = ()=> {
  127. console.log('SDK加载失败');
  128. this.sdkLoadStatus = 'error';
  129. }
  130. },
  131. mounted() {
  132. // 获取窗口尺寸
  133. this.getInitSize();
  134. window.onresize = () => {
  135. console.log('窗口尺寸变化');
  136. this.getInitSize()
  137. };
  138. // 初始化页面监听事件
  139. this.initListener();
  140. // 定时器 监听sdk加载状态, 加载成功和失败则停止定时器, 加载中则继续监听
  141. let timer = setInterval(() => {
  142. console.log('SDK加载状态:', this.sdkLoadStatus);
  143. if (this.sdkLoadStatus === 'success' || window?.rtc_sdk?.MediaSdk) {
  144. this.sdkLoadStatus = 'success';
  145. console.log('SDK加载成功');
  146. clearInterval(timer);
  147. // 初始化webRTC
  148. this.initWebRtc();
  149. }
  150. if (this.sdkLoadStatus === 'error') {
  151. console.log('SDK加载失败');
  152. clearInterval(timer);
  153. }
  154. })
  155. },
  156. // 页面销毁前触发
  157. beforeDestroy() {
  158. // 销毁页面监听事件
  159. this.destroyListener();
  160. },
  161. methods: {
  162. // 初始化页面监听事件
  163. initListener() {
  164. // 禁止双击缩放
  165. document.addEventListener('dblclick', this.preventDefault);
  166. // 添加监听 页面显示或隐藏 事件
  167. document.addEventListener('visibilitychange', this.visibilitychanged);
  168. },
  169. // 销毁页面监听事件
  170. destroyListener() {
  171. // 允许双击缩放
  172. document.removeEventListener('dblclick', this.preventDefault);
  173. // 移除监听 页面显示或隐藏 事件
  174. document.removeEventListener('visibilitychange', this.visibilitychanged);
  175. },
  176. // 阻止默认事件
  177. preventDefault(e) {
  178. e.preventDefault();
  179. },
  180. // 监听 页面显示或隐藏 执行的函数
  181. visibilitychanged() {
  182. // 获取当前环境
  183. const env = isBrowserEnvironment();
  184. // 获取当前页面的可见性状态
  185. const visibilityState = document.visibilityState;
  186. if (visibilityState === 'visible') {
  187. // 页面显示时的逻辑
  188. // 网页重载
  189. if (env.isBrowser && env.isTopWindow && env.isIPhone) {
  190. location.reload();
  191. }
  192. } else if (visibilityState === 'hidden') {
  193. // 页面隐藏时的逻辑
  194. // video.pause();
  195. } else if (visibilityState === 'prerender') {
  196. // 页面预渲染时的逻辑
  197. console.log('页面处于预渲染状态');
  198. } else if (visibilityState === 'unloaded') {
  199. // 页面即将卸载时的逻辑 移除监听
  200. document.removeEventListener('visibilitychange', this.visibilitychanged);
  201. }
  202. },
  203. // 获取初始化尺寸
  204. getInitSize() {
  205. // 获取窗口尺寸
  206. this.pageData.height = window.innerHeight;
  207. this.pageData.width = window.innerWidth;
  208. // 获取底部菜单高度
  209. let {clientHeight: h} = document.getElementById("foot-menu-wrap");
  210. this.pageData.footMenuHeight = h;
  211. // 计算视频尺寸 webRTC需要做成16:9的画面
  212. let videoWidth = this.pageData.width;
  213. let videoHeight = this.pageData.height - this.pageData.footMenuHeight;
  214. // 计算当前视口的宽高比
  215. const currentRatio = videoWidth / videoHeight;
  216. console.log(`当前视口的宽高比: ${currentRatio}`);
  217. // 9:16 的目标比例
  218. const targetRatio = 9 / 16;
  219. // 判断当前视口的宽高比与目标比例的关系
  220. if (currentRatio > targetRatio) {
  221. // 当前视口的宽高比大于目标比例,说明宽度“过宽”,需要以高度为基准
  222. console.log("当前视口宽度过宽,应以高度为基准调整宽度");
  223. this.pageData.videoWidth = videoHeight * targetRatio;
  224. this.pageData.videoHeight = videoHeight;
  225. console.log(`1目标: 宽${this.pageData.videoWidth},高${this.pageData.videoHeight}`);
  226. } else {
  227. // 当前视口的宽高比小于目标比例,说明高度“过高”,需要以宽度为基准
  228. console.log("当前视口高度过高,应以宽度为基准调整高度");
  229. this.pageData.videoWidth = videoWidth / targetRatio;
  230. this.pageData.videoHeight = videoWidth;
  231. console.log(`2目标: 宽${this.pageData.videoWidth},高${this.pageData.videoHeight}`);
  232. }
  233. },
  234. // 获取卡的信息
  235. async getConnectData(params) {
  236. try {
  237. const res = await this.$axios.$post('/resources/user/cloud/connect', { userCardId: params. userCardId}, {
  238. headers: {
  239. merchantSign: params.merchantSign,
  240. },
  241. });
  242. if (!res.success) {
  243. // TODO 提示错误信息 和 日志参数及上报
  244. // res.status状态码枚举值 0: 正常
  245. const statusEnum = {
  246. // 5200:RBD资源挂载中
  247. 5200: '网络异常,请稍后重试',
  248. // 入使用排队9.9,年卡
  249. 5220: '云手机正在一键修复中',
  250. 5203: '正在排队中,请稍等',
  251. // 9.9年卡连接异常,重新进入排队
  252. 5204: '云机异常,正在为你重新分配云机',
  253. 5228: '卡的网络状态为差',
  254. 5229: '接口返回链接信息缺失',
  255. }
  256. // 提示错误信息
  257. this.$toast(statusEnum[res.status] || '网络异常,请稍后重试')
  258. return Promise.reject(new Error(statusEnum[res.status] || '网络异常,请稍后重试'));
  259. }
  260. return res.data;
  261. }catch (error) {
  262. console.log('error connectAxios:', error);
  263. return Promise.reject(error);
  264. }
  265. },
  266. // 判断卡的连接方式
  267. async judgeConnectType(cardData) {
  268. try {
  269. // 不支持webRTC跳转到指定的页面进行拉流
  270. if (!cardData.isWebrtc) {
  271. // 跳转指定页面
  272. location.replace(`${location.origin}/h5/webRtcYJ/WXtrialInterface.html${location.search}`)
  273. return Promise.reject();
  274. }
  275. // 是否支持webRTC
  276. if (!this.isSupportRtc) {
  277. // TODO 提示错误信息 和 日志参数及上报
  278. this.$dialog.alert({
  279. title: '提示',
  280. message: '当前环境不支持使用,可下载谷歌浏览器或客户端进行使用',
  281. confirmButtonText: '确定',
  282. confirmButtonColor: '#3cc51f',
  283. callback: () => {
  284. // TODO 关闭页面
  285. }
  286. })
  287. return Promise.reject(new Error('当前浏览器不支持webRTC'));
  288. }
  289. // webRtc连接,需获取连接中转地址
  290. if (cardData.webrtcNetworkAnalysis) {
  291. // 如果有网络分析的请求地址, 则请求,否则失败
  292. const { data: webrtcNetworkAnalysisReq }= await request.get(cardData.webrtcNetworkAnalysis); // 这个接口单独使用axios请求, 因为返回的数据跟封装的数据结构不一统一,是其他平台的接口,所以单独请求
  293. if (webrtcNetworkAnalysisReq !== null && webrtcNetworkAnalysisReq.success && webrtcNetworkAnalysisReq.data) {
  294. // 保存获取的连接地址到connect的请求的响应中, 方便后面使用
  295. cardData.webrtcNetwork = webrtcNetworkAnalysisReq.data;
  296. return cardData;
  297. }else{
  298. // TODO 提示错误信息 和 日志参数及上报
  299. return Promise.reject(new Error('网络分析请求失败'));
  300. }
  301. }else{
  302. // TODO 提示错误信息 和 日志参数及上报
  303. return Promise.reject(new Error('网络分析请求地址不存在'));
  304. }
  305. } catch (error) {
  306. console.log('判断卡的连接方式', error);
  307. return Promise.reject(error);
  308. }
  309. },
  310. // 初始化webRTC及相关配置
  311. initWebRtc() {
  312. // 检查连接卡信息是否存在,不存在则循环等待,直到存在为止
  313. if (!Object.keys(this.connectData).length) {
  314. setTimeout(() => {
  315. this.initWebRtc();
  316. }, 50);
  317. return;
  318. }
  319. // 获取挂载的容器元素
  320. const videoRef = document.getElementById("videoRef");
  321. // 解构connectData中的数据
  322. const { sn: topic, cardToken: authToken, localIp, internetHttps, internetHttp, webrtcNetwork, webrtcTransferCmnet, webrtcTransferTelecom, webrtcTransferUnicom, videoCode } = this.connectData;
  323. // 判断长连接的协议方式
  324. const isWss = location.protocol === 'https:';
  325. // 生成连接地址
  326. const url = `${isWss ? 'wss://' : 'ws://'}${isWss ? internetHttps : internetHttp}/nats`;
  327. // 统一使用三网解析地址
  328. const ICEServerUrl = [
  329. { "CMNET": webrtcNetwork }, // 移动
  330. { 'CHINANET-GD': webrtcNetwork }, // 电信
  331. { 'UNICOM-GD': webrtcNetwork }, // 联通
  332. ];
  333. // 配置连接参数
  334. const connection = {
  335. mount: videoRef,
  336. displaySize: { // 视频在页面显示的尺寸 必填
  337. width: this.pageData.videoWidth,
  338. height: this.pageData.videoHeight,
  339. },
  340. topic, // SN号 必填
  341. url, //信令服务地址 必填
  342. ICEServerUrl,
  343. forwardServerAddress: '', // 转发服务器地址
  344. ip: localIp, // 实例ip
  345. controlToken: '', // 控制token
  346. width: 720, // 推流视频宽度 必填
  347. height: 1080, // 推流视频高度 必填
  348. cardWidth: 0, // 云机系统分辨率 宽 必填
  349. cardHeight: 0, // 云机系统分辨率 高 必填
  350. cardDensity: 0, // 云机系统显示 密度 必填
  351. authToken, //拉流鉴权 token 必填
  352. quality: '高清',// 画质(码率) 超清 | 高清 | 标清 | 流畅
  353. fps: 30, //必填
  354. videoCodec: videoCode, // 视频编码格式 必填
  355. videoCodecMethod: true, // 硬编true | 软编false
  356. isMuted: false, // 是否静音
  357. isAllowedOpenCamera: true, // 是否允许打开摄像头
  358. sendFollow: true, // 是否允许主控转发文本到实例
  359. callback: (event)=> {}
  360. };
  361. // 获取SDK类
  362. const MediaSdk = window.rtc_sdk.MediaSdk;
  363. // 初始化 SDK
  364. this.engine = new MediaSdk();
  365. console.log('RtcEngineConfig==', connection)
  366. // 初始化 SDK 并传入连接参数
  367. this.engine.RtcEngine(connection);
  368. // 监听回调方法
  369. this.eventCallbackFunction();
  370. },
  371. // webRTC状态回调监听回调方法
  372. eventCallbackFunction() {
  373. const engine = this.engine;
  374. // 连接成功
  375. engine.on('CONNECT_SUCCESS', (r) => {
  376. console.log("webrtc连接成功====★★★★★", r);
  377. if (r.code === 1005) { // 1005: 拉流鉴权成功
  378. this.$toast.clear();
  379. // 播放视频
  380. setTimeout(() => {
  381. engine.mediaElement.node.play();
  382. }, 50)
  383. }
  384. });
  385. // 连接关闭
  386. engine.on('CONNECT_CLOSE', (r) => {
  387. console.log("webrtc关闭====★★★★★", r);
  388. });
  389. // 连接异常
  390. engine.on('CONNECT_ERROR', (r) => {
  391. console.log("webrtc异常状态====★★★★★", r);
  392. });
  393. },
  394. // 三键
  395. sendKey (keyCode) {
  396. console.log('sendKey', keyCode);
  397. this.engine.sendKey(keyCode);
  398. }
  399. }
  400. }
  401. </script>
  402. <style lang="scss" scoped>
  403. html{
  404. background-color: #000;
  405. }
  406. // 动态生成 从 0 到 100px 的样式
  407. @for $i from 0 through 100 {
  408. .mb-#{$i} {
  409. margin-bottom: #{$i}px;
  410. }
  411. .mt-#{$i} {
  412. margin-top: #{$i}px;
  413. }
  414. .ml-#{$i} {
  415. margin-left: #{$i}px;
  416. }
  417. .mr-#{$i} {
  418. margin-right: #{$i}px;
  419. }
  420. }
  421. $-radeus-12: 12px;
  422. $-bg-yellow: rgb(255, 253, 241);
  423. .rtc-page{
  424. position: relative;
  425. font-size: 14px;
  426. .video-wrapper{
  427. position: relative;
  428. }
  429. }
  430. .cover-bg{
  431. background-color: #000;
  432. }
  433. #foot-menu-wrap{
  434. border-width: 0px;
  435. position: absolute;
  436. left: 0px;
  437. bottom: 0px;
  438. width: 100%;
  439. height: 40px;
  440. background: inherit;
  441. background-color: rgba(0, 12, 23, 1);
  442. border: none;
  443. border-radius: 0px;
  444. -moz-box-shadow: none;
  445. -webkit-box-shadow: none;
  446. box-shadow: none;
  447. z-index: 5;
  448. display: flex;
  449. justify-content: space-evenly;
  450. align-items: center;
  451. color: #fff;
  452. // .foot-menu{
  453. // width: 40px;
  454. // }
  455. }
  456. </style>