rtc.vue 19 KB

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