RtcPrivateVideo.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. <template>
  2. <div>
  3. <el-dialog
  4. v-dialogDrag
  5. top="5vh"
  6. custom-class="rtc-private-video-dialog"
  7. :title="title"
  8. :width="width"
  9. :visible.sync="showRoom"
  10. :close-on-click-modal="false"
  11. :close-on-press-escape="false"
  12. :before-close="onQuit">
  13. <div class="rtc-private-video">
  14. <div v-show="isVideo" class="rtc-video-box">
  15. <div class="rtc-video-friend" v-loading="!isChating" element-loading-text="等待对方接听..."
  16. element-loading-background="rgba(0, 0, 0, 0.1)">
  17. <head-image class="friend-head-image" :id="friend.id" :size="80" :name="friend.nickName"
  18. :url="friend.headImage" :isShowUserInfo="false" radius="0">
  19. </head-image>
  20. <video ref="remoteVideo" autoplay=""></video>
  21. </div>
  22. <div class="rtc-video-mine">
  23. <video ref="localVideo" autoplay=""></video>
  24. </div>
  25. </div>
  26. <div v-show="!isVideo" class="rtc-voice-box" v-loading="!isChating" element-loading-text="等待对方接听..."
  27. element-loading-background="rgba(0, 0, 0, 0.1)">
  28. <head-image class="friend-head-image" :id="friend.id" :size="200" :name="friend.nickName"
  29. :url="friend.headImage" :isShowUserInfo="false">
  30. <div class="rtc-voice-name">{{ friend.nickName }}</div>
  31. </head-image>
  32. </div>
  33. <div class="rtc-control-bar">
  34. <div title="取消" class="icon iconfont icon-phone-reject reject"
  35. style="color: red;" @click="onQuit()"></div>
  36. </div>
  37. </div>
  38. </el-dialog>
  39. <rtc-private-acceptor v-if="!isHost && isWaiting" ref="acceptor" :friend="friend" :mode="mode" @accept="onAccept"
  40. @reject="onReject"></rtc-private-acceptor>
  41. </div>
  42. </template>
  43. <script>
  44. import HeadImage from '../common/HeadImage.vue';
  45. import RtcPrivateAcceptor from './RtcPrivateAcceptor.vue';
  46. import ImWebRtc from '@/api/webrtc';
  47. import ImCamera from '@/api/camera';
  48. import RtcPrivateApi from '@/api/rtcPrivateApi'
  49. export default {
  50. name: 'rtcPrivateVideo',
  51. components: {
  52. HeadImage,
  53. RtcPrivateAcceptor
  54. },
  55. data() {
  56. return {
  57. camera: new ImCamera(), // 摄像头和麦克风
  58. webrtc: new ImWebRtc(), // webrtc相关
  59. API: new RtcPrivateApi(), // API
  60. audio: new Audio(), // 呼叫音频
  61. showRoom: false,
  62. friend: {},
  63. isHost: false, // 是否发起人
  64. state: "CLOSE", // CLOSE:关闭 WAITING:等待呼叫或接听 CHATING:聊天中 ERROR:出现异常
  65. mode: 'video', // 模式 video:视频聊 voice:语音聊天
  66. localStream: null, // 本地视频流
  67. remoteStream: null, // 对方视频流
  68. videoTime: 0,
  69. videoTimer: null,
  70. heartbeatTimer: null,
  71. candidates: [],
  72. }
  73. },
  74. methods: {
  75. open(rtcInfo) {
  76. this.showRoom = true;
  77. this.mode = rtcInfo.mode;
  78. this.isHost = rtcInfo.isHost;
  79. this.friend = rtcInfo.friend;
  80. if (this.isHost) {
  81. this.onCall();
  82. }
  83. },
  84. initAudio() {
  85. let url = require(`@/assets/audio/call.wav`);
  86. this.audio.src = url;
  87. this.audio.loop = true;
  88. },
  89. initRtc() {
  90. this.webrtc.init(this.configuration)
  91. this.webrtc.setupPeerConnection((stream) => {
  92. this.$refs.remoteVideo.srcObject = stream;
  93. this.remoteStream = stream;
  94. })
  95. // 监听候选信息
  96. this.webrtc.onIcecandidate((candidate) => {
  97. if (this.state == "CHATING") {
  98. // 连接已就绪,直接发送
  99. this.API.sendCandidate(this.friend.id, candidate);
  100. } else {
  101. // 连接未就绪,缓存起来,连接后再发送
  102. this.candidates.push(candidate)
  103. }
  104. })
  105. // 监听连接成功状态
  106. this.webrtc.onStateChange((state) => {
  107. if (state == "connected") {
  108. console.log("webrtc连接成功")
  109. } else if (state == "disconnected") {
  110. console.log("webrtc连接断开")
  111. }
  112. })
  113. },
  114. onCall() {
  115. if (!this.checkDevEnable()) {
  116. this.close();
  117. }
  118. // 初始化webrtc
  119. this.initRtc();
  120. // 启动心跳
  121. this.startHeartBeat();
  122. // 打开摄像头
  123. this.openStream().then(() => {
  124. this.webrtc.setStream(this.localStream);
  125. this.webrtc.createOffer().then((offer) => {
  126. // 发起呼叫
  127. this.API.call(this.friend.id, this.mode, offer).then(() => {
  128. // 直接进入聊天状态
  129. this.state = "WAITING";
  130. // 播放呼叫铃声
  131. this.audio.play();
  132. }).catch(() => {
  133. this.close();
  134. })
  135. })
  136. }).catch(() => {
  137. // 呼叫方必须能打开摄像头,否则无法正常建立连接
  138. this.close();
  139. })
  140. },
  141. onAccept() {
  142. if (!this.checkDevEnable()) {
  143. this.API.failed(this.friend.id, "对方设备不支持通话")
  144. this.close();
  145. return;
  146. }
  147. // 进入房间
  148. this.showRoom = true;
  149. this.state = "CHATING";
  150. // 停止呼叫铃声
  151. this.audio.pause();
  152. // 初始化webrtc
  153. this.initRtc();
  154. // 打开摄像头
  155. this.openStream().finally(() => {
  156. this.webrtc.setStream(this.localStream);
  157. this.webrtc.createAnswer(this.offer).then((answer) => {
  158. this.API.accept(this.friend.id, answer);
  159. // 记录时长
  160. this.startChatTime();
  161. // 清理定时器
  162. this.waitTimer && clearTimeout(this.waitTimer);
  163. })
  164. })
  165. },
  166. onReject() {
  167. console.log("onReject")
  168. // 退出通话
  169. this.API.reject(this.friend.id);
  170. // 退出
  171. this.close();
  172. },
  173. onHandup() {
  174. this.API.handup(this.friend.id)
  175. this.$message.success("您已挂断,通话结束")
  176. this.close();
  177. },
  178. onCancel() {
  179. this.API.cancel(this.friend.id)
  180. this.$message.success("已取消呼叫,通话结束")
  181. this.close();
  182. },
  183. onRTCMessage(msg) {
  184. // 除了发起通话,如果在关闭状态就无需处理
  185. if (msg.type != this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE &&
  186. msg.type != this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO &&
  187. this.isClose) {
  188. return;
  189. }
  190. // RTC信令处理
  191. switch (msg.type) {
  192. case this.$enums.MESSAGE_TYPE.RTC_CALL_VOICE:
  193. this.onRTCCall(msg, 'voice')
  194. break;
  195. case this.$enums.MESSAGE_TYPE.RTC_CALL_VIDEO:
  196. this.onRTCCall(msg, 'video')
  197. break;
  198. case this.$enums.MESSAGE_TYPE.RTC_ACCEPT:
  199. this.onRTCAccept(msg)
  200. break;
  201. case this.$enums.MESSAGE_TYPE.RTC_REJECT:
  202. this.onRTCReject(msg)
  203. break;
  204. case this.$enums.MESSAGE_TYPE.RTC_CANCEL:
  205. this.onRTCCancel(msg)
  206. break;
  207. case this.$enums.MESSAGE_TYPE.RTC_FAILED:
  208. this.onRTCFailed(msg)
  209. break;
  210. case this.$enums.MESSAGE_TYPE.RTC_HANDUP:
  211. this.onRTCHandup(msg)
  212. break;
  213. case this.$enums.MESSAGE_TYPE.RTC_CANDIDATE:
  214. this.onRTCCandidate(msg)
  215. break;
  216. }
  217. },
  218. onRTCCall(msg, mode) {
  219. this.offer = JSON.parse(msg.content);
  220. this.isHost = false;
  221. this.mode = mode;
  222. this.$http({
  223. url: `/friend/find/${msg.sendId}`,
  224. method: 'get'
  225. }).then((friend) => {
  226. this.friend = friend;
  227. this.state = "WAITING";
  228. this.audio.play();
  229. this.startHeartBeat();
  230. // 30s未接听自动挂掉
  231. this.waitTimer = setTimeout(() => {
  232. this.API.failed(this.friend.id, "对方无应答");
  233. this.$message.error("您未接听");
  234. this.close();
  235. }, 30000)
  236. })
  237. },
  238. onRTCAccept(msg) {
  239. if (msg.selfSend) {
  240. // 在其他设备接听
  241. this.$message.success("已在其他设备接听");
  242. this.close();
  243. } else {
  244. // 对方接受了的通话
  245. let offer = JSON.parse(msg.content);
  246. this.webrtc.setRemoteDescription(offer);
  247. // 状态为聊天中
  248. this.state = 'CHATING'
  249. // 停止播放语音
  250. this.audio.pause();
  251. // 发送candidate
  252. this.candidates.forEach((candidate) => {
  253. this.API.sendCandidate(this.friend.id, candidate);
  254. })
  255. // 开始计时
  256. this.startChatTime()
  257. }
  258. },
  259. onRTCReject(msg) {
  260. if (msg.selfSend) {
  261. this.$message.success("已在其他设备拒绝");
  262. this.close();
  263. } else {
  264. this.$message.error("对方拒绝了您的通话请求");
  265. this.close();
  266. }
  267. },
  268. onRTCFailed(msg) {
  269. // 呼叫失败
  270. this.$message.error(msg.content)
  271. this.close();
  272. },
  273. onRTCCancel() {
  274. // 对方取消通话
  275. this.$message.success("对方取消了呼叫");
  276. this.close();
  277. },
  278. onRTCHandup() {
  279. // 对方挂断
  280. this.$message.success("对方已挂断");
  281. this.close();
  282. },
  283. onRTCCandidate(msg) {
  284. let candidate = JSON.parse(msg.content);
  285. this.webrtc.addIceCandidate(candidate);
  286. },
  287. openStream() {
  288. return new Promise((resolve, reject) => {
  289. if (this.isVideo) {
  290. // 打开摄像头+麦克风
  291. this.camera.openVideo().then((stream) => {
  292. this.localStream = stream;
  293. this.$nextTick(() => {
  294. this.$refs.localVideo.srcObject = stream;
  295. this.$refs.localVideo.muted = true;
  296. })
  297. resolve(stream);
  298. }).catch((e) => {
  299. this.$message.error("打开摄像头失败")
  300. console.log("本摄像头打开失败:" + e.message)
  301. reject(e);
  302. })
  303. } else {
  304. // 打开麦克风
  305. this.camera.openAudio().then((stream) => {
  306. this.localStream = stream;
  307. this.$refs.localVideo.srcObject = stream;
  308. resolve(stream);
  309. }).catch((e) => {
  310. this.$message.error("打开麦克风失败")
  311. console.log("打开麦克风失败:" + e.message)
  312. reject(e);
  313. })
  314. }
  315. })
  316. },
  317. startChatTime() {
  318. this.videoTime = 0;
  319. this.videoTimer && clearInterval(this.videoTimer);
  320. this.videoTimer = setInterval(() => {
  321. this.videoTime++;
  322. }, 1000)
  323. },
  324. checkDevEnable() {
  325. // 检测摄像头
  326. if (!this.camera.isEnable()) {
  327. this.message.error("访问摄像头失败");
  328. return false;
  329. }
  330. // 检测webrtc
  331. if (!this.webrtc.isEnable()) {
  332. this.message.error("初始化RTC失败,原因可能是: 1.服务器缺少ssl证书 2.您的设备不支持WebRTC");
  333. return false;
  334. }
  335. return true;
  336. },
  337. startHeartBeat() {
  338. // 每15s推送一次心跳
  339. this.heartbeatTimer && clearInterval(this.heartbeatTimer);
  340. this.heartbeatTimer = setInterval(() => {
  341. this.API.heartbeat(this.friend.id);
  342. }, 15000)
  343. },
  344. close() {
  345. this.showRoom = false;
  346. this.camera.close();
  347. this.webrtc.close();
  348. this.audio.pause();
  349. this.videoTime = 0;
  350. this.videoTimer && clearInterval(this.videoTimer);
  351. this.heartbeatTimer && clearInterval(this.heartbeatTimer);
  352. this.waitTimer && clearTimeout(this.waitTimer);
  353. this.videoTimer = null;
  354. this.heartbeatTimer = null;
  355. this.waitTimer = null;
  356. this.state = 'CLOSE';
  357. this.candidates = [];
  358. },
  359. onQuit() {
  360. if (this.isChating) {
  361. this.onHandup()
  362. } else if (this.isWaiting) {
  363. this.onCancel();
  364. } else {
  365. this.close();
  366. }
  367. }
  368. },
  369. computed: {
  370. width() {
  371. return this.isVideo ? '960px' : '360px'
  372. },
  373. title() {
  374. let strTitle = `${this.modeText}通话-${this.friend.nickName}`;
  375. if (this.isChating) {
  376. strTitle += `(${this.currentTime})`;
  377. } else if (this.isWaiting) {
  378. strTitle += `(呼叫中)`;
  379. }
  380. return strTitle;
  381. },
  382. currentTime() {
  383. let min = Math.floor(this.videoTime / 60);
  384. let sec = this.videoTime % 60;
  385. let strTime = min < 10 ? "0" : "";
  386. strTime += min;
  387. strTime += ":"
  388. strTime += sec < 10 ? "0" : "";
  389. strTime += sec;
  390. return strTime;
  391. },
  392. configuration() {
  393. const iceServers = this.$store.state.configStore.webrtc.iceServers;
  394. return {
  395. iceServers: iceServers
  396. }
  397. },
  398. isVideo() {
  399. return this.mode == "video"
  400. },
  401. modeText() {
  402. return this.isVideo ? "视频" : "语音";
  403. },
  404. isChating() {
  405. return this.state == "CHATING";
  406. },
  407. isWaiting() {
  408. return this.state == "WAITING";
  409. },
  410. isClose() {
  411. return this.state == "CLOSE";
  412. }
  413. },
  414. mounted() {
  415. // 初始化音频文件
  416. this.initAudio();
  417. },
  418. created() {
  419. // 监听页面刷新事件
  420. window.addEventListener('beforeunload', () => {
  421. this.onQuit();
  422. });
  423. },
  424. beforeUnmount() {
  425. this.onQuit();
  426. }
  427. }
  428. </script>
  429. <style lang="scss">
  430. .rtc-private-video {
  431. position: relative;
  432. .el-loading-text {
  433. color: white !important;
  434. font-size: 16px !important;
  435. }
  436. .path {
  437. stroke: white !important;
  438. }
  439. .rtc-video-box {
  440. position: relative;
  441. background-color: #eeeeee;
  442. .rtc-video-friend {
  443. height: 70vh;
  444. .friend-head-image {
  445. position: absolute;
  446. }
  447. video {
  448. width: 100%;
  449. height: 100%;
  450. object-fit: cover;
  451. transform: rotateY(180deg);
  452. }
  453. }
  454. .rtc-video-mine {
  455. position: absolute;
  456. z-index: 99999;
  457. width: 25vh;
  458. right: 0;
  459. bottom: -1px;
  460. video {
  461. width: 100%;
  462. object-fit: cover;
  463. transform: rotateY(180deg);
  464. }
  465. }
  466. }
  467. .rtc-voice-box {
  468. position: relative;
  469. display: flex;
  470. justify-content: center;
  471. align-items: center;
  472. width: 100%;
  473. height: 300px;
  474. background-color: var(--im-color-primary-light-9);
  475. .rtc-voice-name {
  476. text-align: center;
  477. font-size: 20px;
  478. font-weight: 600;
  479. }
  480. }
  481. .rtc-control-bar {
  482. display: flex;
  483. justify-content: space-around;
  484. padding: 10px;
  485. .icon {
  486. font-size: 50px;
  487. cursor: pointer;
  488. }
  489. }
  490. }
  491. </style>