voiceBroadcast.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. <template>
  2. <div class="voiceBroadcast">
  3. <div class="null-box" @click="offButton()"></div>
  4. <p class="get-button"
  5. v-if="showPermissionButton"
  6. @click="handlePermissionCheck"
  7. >点击获取麦克风权限</p>
  8. <div class="broadcast" v-if="!showPermissionButton">
  9. <p class="broadcast_t">语音广播<span>选择喇叭位置</span></p>
  10. <!-- 按钮部分 -->
  11. <div class="trumpet-max-box">
  12. <div @click="trumpetClick(index)" class="trumpet-for-box"
  13. :class="item.type?'trumpet-color-a':'trumpet-color-b'" v-for="(item,index) in trumpetList" :key="index">
  14. <img :src="imagesUrl('commonality/icon_sskz_zc.png')" v-if="!item.type">
  15. <img :src="imagesUrl('commonality/icon_sskz_xz.png')" v-if="item.type">
  16. {{item.deviceName}}
  17. </div>
  18. </div>
  19. <div class="broadcast_m no-long-press">
  20. <div class="broadcast_m_t no-long-press"
  21. :class="liveType?'broadcast_m_t_back_a':'broadcast_m_t_back_b'"
  22. @touchstart="handleTouchStart"
  23. @touchmove="handleTouchMove"
  24. @touchend="handleTouchEnd"
  25. @touchcancel="handleTouchEnd"
  26. @contextmenu.prevent="handleContextMenu">
  27. </div>
  28. <p class="broadcast_m_b no-long-press" v-if="!liveType">按住说话,录入广播内容</p>
  29. <p class="broadcast_m_b no-long-press" v-if="liveType">松开发送,向上滑动取消发送</p>
  30. </div>
  31. </div>
  32. </div>
  33. </template>
  34. <script>
  35. import { Toast,Dialog } from 'vant';
  36. import Recorder from 'recorder-core';
  37. import 'recorder-core/src/engine/mp3';
  38. import 'recorder-core/src/engine/mp3-engine';
  39. import { systemFileUpload,iotAppSpeakerPlayVoice } from "@/api/index";
  40. export default {
  41. name: 'voiceBroadcast',
  42. props: {
  43. trumpetList:{
  44. type: Array,
  45. default: () => []
  46. },
  47. },
  48. data () {
  49. return {
  50. //广播相关
  51. voiceType:false,
  52. liveType: false,
  53. sendLock: true, //发送锁,当为true时上锁,false时解锁发送
  54. isEvacuate: true, //疏散按钮控制,当为true时候执行疏散
  55. //H5
  56. recording: false,
  57. isInitializing: false, // 新增:防止重复初始化
  58. recorder: null,
  59. audioBlob: null,
  60. audioPath: null,
  61. //H5 拖拽
  62. touchStartY: 0,
  63. isLongPress: false,
  64. hasMoved: false,
  65. longPressTimer: null,
  66. moveDirection: null,
  67. hasTriggeredMethod3: false, // 确保方法3只触发一次
  68. //麦克风权限相关
  69. showPermissionButton:true,
  70. permissionStatus: null, // 保存权限状态
  71. stream:null,
  72. }
  73. },
  74. created(){
  75. },
  76. mounted(){
  77. },
  78. methods:{
  79. offButton(){
  80. this.$parent.voiceButton();
  81. },
  82. handleTouchStart(event) {
  83. let self = this;
  84. // 彻底阻止所有默认行为
  85. event.preventDefault();
  86. event.stopPropagation();
  87. this.touchStartY = event.touches[0].clientY;
  88. this.hasMoved = false;
  89. this.moveDirection = null;
  90. this.hasTriggeredMethod3 = false;
  91. Promise.all([
  92. //获取开发配置
  93. self.delRecord(),
  94. ]).then((result) => {
  95. // 设置长按定时器
  96. self.longPressTimer = setTimeout(() => {
  97. self.isLongPress = true;
  98. self.startRecord(); // 执行方法1
  99. }, 300); // 300ms触发长按
  100. }).catch((error) => {})
  101. },
  102. // 触摸移动
  103. handleTouchMove(event) {
  104. // 彻底阻止所有默认行为
  105. event.preventDefault();
  106. event.stopPropagation();
  107. if (!this.isLongPress) return;
  108. const currentY = event.touches[0].clientY;
  109. const deltaY = currentY - this.touchStartY;
  110. // 检测滑动方向(向上滑动为负值)
  111. if (Math.abs(deltaY) > 75) { // 增加阈值避免误触
  112. this.hasMoved = true;
  113. this.moveDirection = deltaY < 0 ? 'up' : 'down';
  114. // 向上滑动执行方法3(确保只触发一次)
  115. if (this.moveDirection === 'up' && !this.hasTriggeredMethod3) {
  116. this.hasTriggeredMethod3 = true;
  117. this.delRecord();
  118. }
  119. }
  120. },
  121. // 触摸结束
  122. handleTouchEnd() {
  123. // 清除长按定时器
  124. clearTimeout(this.longPressTimer);
  125. if (this.isLongPress) {
  126. this.stopRecord(); // 执行方法2
  127. }
  128. // 重置状态
  129. this.isLongPress = false;
  130. this.hasMoved = false;
  131. this.moveDirection = null;
  132. },
  133. // 关键:禁用上下文菜单(长按菜单)
  134. handleContextMenu(e) {
  135. e.preventDefault();
  136. return false;
  137. },
  138. //获取权限并且初始化
  139. async initRecorder() {
  140. try {
  141. // 获取麦克风权限
  142. this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  143. this.recorder = new Recorder({
  144. type: "mp3", // 输出格式
  145. bitRate: 128, // 比特率
  146. sampleRate: 44100 // 采样率
  147. });
  148. this.recorder.open(() => {
  149. // console.log("录音器初始化成功");
  150. this.recorder.start(); // 初始化后立即开始录音
  151. }, (error) => {
  152. console.error("录音器初始化失败:", error);
  153. Toast.fail('麦克风权限获取失败,请允许麦克风权限');
  154. });
  155. } catch (err) {
  156. console.error("获取麦克风失败:", err);
  157. Toast.fail('请允许麦克风权限');
  158. }
  159. },
  160. // 开始录音
  161. async startRecord() {
  162. let self = this;
  163. let num = 0;
  164. for (let i = 0; i < self.trumpetList.length; i++) {
  165. if (self.trumpetList[i].type) {
  166. num++
  167. }
  168. }
  169. if (num == 0) {
  170. Toast.fail('请选择喇叭');
  171. return
  172. }
  173. if (!this.recorder) {
  174. await this.initRecorder();
  175. this.startRecord();
  176. return;
  177. }
  178. this.recorder.start(); // 初始化后立即开始录音
  179. this.recording = true;
  180. this.liveType = true;
  181. Toast('录音开始');
  182. },
  183. // 停止录音
  184. stopRecord() {
  185. if (!this.recorder || !this.recording) return;
  186. this.recorder.stop((blob, duration) => {
  187. this.recording = false;
  188. this.liveType = false;
  189. this.audioBlob = blob;
  190. // 生成临时文件路径
  191. this.audioPath = URL.createObjectURL(blob);
  192. this.uploadAudio();
  193. }, (error) => {
  194. console.error("录音失败:", error);
  195. Toast('录音失败');
  196. });
  197. },
  198. delRecord(){
  199. console.log('delRecord1',this.recorder)
  200. if(this.recorder){
  201. console.log('delRecord22',this.recorder)
  202. this.recorder.stop((blob, duration) => {
  203. this.$set(this,'recording',false);
  204. this.$set(this,'liveType',false);
  205. this.$set(this,'audioBlob',null);
  206. this.$set(this,'audioPath',null);
  207. })
  208. this.$set(this,'recorder',null);
  209. }
  210. },
  211. // 上传录音文件
  212. uploadAudio() {
  213. let self = this;
  214. if (!this.audioBlob) return;
  215. // 1. 通过fetch获取Blob数据
  216. fetch(this.audioPath)
  217. .then(response => response.blob())
  218. .then(blob => {
  219. // 2. 将Blob转换为File对象
  220. const file = new File([blob], 'audio.mp3', { type: 'audio/mp3' });
  221. let formData = new FormData();
  222. formData.append("file", file);
  223. formData.append("ifAsy", false);
  224. systemFileUpload(formData).then(response => {
  225. console.log('response',response)
  226. let url = localStorage.getItem('fileBrowseEnvironment') + '/' + response.data.url;
  227. self.iotAppSpeakerPlayVoice(url);
  228. });
  229. })
  230. .catch(error => {
  231. console.error('获取Blob数据失败', error);
  232. });
  233. },
  234. iotAppSpeakerPlayVoice(text){
  235. let self = this;
  236. let list = [];
  237. for (let i = 0; i < self.trumpetList.length; i++) {
  238. if (self.trumpetList[i].type) {
  239. list.push(self.trumpetList[i].deviceNo)
  240. }
  241. }
  242. let obj = {
  243. deviceNo: list.join(','),
  244. voiceUrls: text,
  245. cycle: 1,
  246. level: 1000,
  247. };
  248. iotAppSpeakerPlayVoice(obj).then(response => {
  249. Toast.success('发送成功');
  250. });
  251. },
  252. //点击选择喇叭
  253. trumpetClick(index) {
  254. this.trumpetList[index].type = !this.trumpetList[index].type;
  255. },
  256. imagesUrl(imgUrl) {
  257. return 'https://zj-wechat.oss-cn-beijing.aliyuncs.com/xcx_images/xcx_v3/' + imgUrl
  258. },
  259. /*=====================获取权限=====================*/
  260. //麦克风权限相关
  261. async checkPermission() {
  262. // 检查浏览器支持性[citation:4][citation:7]
  263. if (!navigator.permissions || !navigator.permissions.query) {
  264. console.warn("浏览器不支持 Permissions API");
  265. return 'unsupported';
  266. }
  267. try {
  268. // 查询麦克风权限状态[citation:2][citation:4][citation:7]
  269. const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
  270. return permissionStatus.state;
  271. } catch (error) {
  272. console.error("查询麦克风权限时发生错误:", error);
  273. return 'error';
  274. }
  275. },
  276. async handlePermissionCheck() {
  277. this.preInitRecorder();
  278. const status = await this.checkPermission();
  279. this.permissionStatus = status;
  280. switch (status) {
  281. case 'granted':
  282. // 已授权,隐藏按钮[citation:7]
  283. this.showPermissionButton = false;
  284. // 这里可以触发后续的录音操作
  285. break;
  286. case 'denied':
  287. // 已拒绝,显示按钮但需要特殊处理[citation:7]
  288. this.showPermissionButton = true;
  289. // 可以在这里显示引导用户去设置页面开启权限的提示
  290. this.showSettingsGuide();
  291. break;
  292. case 'prompt':
  293. // 尝试请求权限[citation:1][citation:2]
  294. try {
  295. await this.requestMicrophonePermission();
  296. } catch (error) {
  297. }
  298. break;
  299. default:
  300. this.showPermissionButton = true;
  301. }
  302. },
  303. // 预初始化录音器(不开始录音)
  304. async preInitRecorder() {
  305. if (this.isInitializing || this.recorder) return;
  306. this.isInitializing = true;
  307. try {
  308. this.recorder = new Recorder({
  309. type: "mp3",
  310. bitRate: 128,
  311. sampleRate: 44100
  312. });
  313. await new Promise((resolve, reject) => {
  314. this.recorder.open(() => {
  315. console.log("录音器预初始化成功");
  316. this.isInitializing = false;
  317. resolve();
  318. }, (error) => {
  319. console.error("录音器预初始化失败:", error);
  320. this.isInitializing = false;
  321. this.recorder = null;
  322. reject(error);
  323. });
  324. });
  325. } catch (error) {
  326. console.error("预初始化失败:", error);
  327. Toast.fail('麦克风权限获取失败,请允许麦克风权限');
  328. }
  329. },
  330. async requestMicrophonePermission() {
  331. try {
  332. // 请求麦克风权限[citation:1][citation:2][citation:5]
  333. this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  334. // 权限获取成功,隐藏按钮
  335. this.showPermissionButton = false;
  336. this.permissionStatus = 'granted';
  337. // 立即停止流,避免占用麦克风[citation:2]
  338. this.stream.getTracks().forEach(track => track.stop());
  339. } catch (error) {
  340. this.permissionStatus = 'denied';
  341. this.showPermissionButton = true;
  342. // 处理不同的错误类型[citation:2]
  343. if (error.name === 'NotAllowedError') {
  344. Toast.fail('用户拒绝授予麦克风权限');
  345. } else if (error.name === 'NotFoundError') {
  346. Toast.fail('未找到麦克风设备');
  347. } else if (error.name === 'NotReadableError') {
  348. Toast.fail('麦克风被其他应用占用');
  349. } else {
  350. Toast.fail('获取麦克风权限失败');
  351. }
  352. }
  353. },
  354. showSettingsGuide() {
  355. Toast.fail('请允许麦克风访问权限');
  356. },
  357. },
  358. }
  359. </script>
  360. <style scoped lang="scss">
  361. .voiceBroadcast{
  362. height: 100%;
  363. width: 100%;
  364. position: fixed;
  365. top: 0;
  366. display: flex;
  367. flex-direction: column;
  368. z-index: 10;
  369. background: rgba(0, 0, 0, 0.4);
  370. .null-box {
  371. flex: 1;
  372. }
  373. .get-button{
  374. width:500px;
  375. height:80px;
  376. line-height:80px;
  377. font-size:32px;
  378. color:#fff;
  379. background-color: #0183FA;
  380. border-radius:6px;
  381. margin:300px 125px;
  382. }
  383. .broadcast {
  384. width: 100%;
  385. background: #FFFFFF;
  386. border-top-left-radius: 20px;
  387. border-top-right-radius: 20px;
  388. padding: 22px 30px 30px;
  389. box-sizing: border-box;
  390. margin-top: 20px;
  391. position: absolute;
  392. bottom: 0;
  393. .broadcast_t {
  394. font-size: 30px;
  395. font-family: PingFang SC;
  396. font-weight: 500;
  397. color: #333333;
  398. line-height: 30px;
  399. >label {
  400. font-size: 24px;
  401. font-family: PingFang SC;
  402. font-weight: 500;
  403. color: #999999;
  404. line-height: 30px;
  405. margin-left: 16px;
  406. }
  407. }
  408. .trumpet-max-box {
  409. display: flex;
  410. justify-content: flex-start;
  411. margin-top: 22px;
  412. flex-wrap: wrap;
  413. .trumpet-for-box {
  414. display: inline-block;
  415. width: auto;
  416. height: 60px;
  417. line-height: 60px;
  418. font-size: 24px;
  419. text-align: center;
  420. cursor: pointer;
  421. overflow: hidden;
  422. border: 1px solid #E0E0E0;
  423. border-radius: 10px;
  424. color: #E0E0E0;
  425. display: flex;
  426. justify-content: center;
  427. margin-right: 20px;
  428. margin-bottom: 10px;
  429. padding: 0 12px;
  430. box-sizing: border-box;
  431. >img {
  432. width: 36px;
  433. height: 34px;
  434. margin: 12px 20px 0 25px;
  435. }
  436. }
  437. .trumpet-color-a {
  438. border: 1px solid #0183FA;
  439. color: #0183FA;
  440. }
  441. .trumpet-color-b {
  442. border: 1px solid #CCCCCC;
  443. color: #999;
  444. }
  445. }
  446. .no-long-press{
  447. // -webkit-touch-callout: none !important;
  448. // -webkit-user-select: none !important;
  449. // -khtml-user-select: none !important;
  450. // -moz-user-select: none !important;
  451. // -ms-user-select: none !important;
  452. // user-select: none !important;
  453. }
  454. .broadcast_m {
  455. width: 100%;
  456. .broadcast_m_t {
  457. width: 142px;
  458. height: 142px;
  459. margin: 30px 0 0 258px;
  460. position: relative;
  461. font-size: 24px;
  462. font-family: PingFang SC;
  463. font-weight: 500;
  464. line-height: 170px;
  465. text-align: center;
  466. >img {
  467. width: 142px;
  468. height: 142px;
  469. position: absolute;
  470. }
  471. >label {
  472. width: 100%;
  473. font-size: 24px;
  474. font-family: PingFang SC;
  475. font-weight: 500;
  476. color: #0183FA;
  477. line-height: 24px;
  478. display: inline-block;
  479. text-align: center;
  480. position: absolute;
  481. top: 76px;
  482. }
  483. /* 按下 */
  484. .press_color {
  485. color: #FFFFFF;
  486. }
  487. /* 松开 */
  488. .slip_color {
  489. color: #0183FA;
  490. }
  491. }
  492. .broadcast_m_b {
  493. font-size: 24px;
  494. font-family: PingFang SC;
  495. font-weight: 500;
  496. color: #999999;
  497. line-height: 24px;
  498. text-align: center;
  499. margin-top: 14px;
  500. }
  501. .broadcast_m_t_back_a {
  502. background: url('https://zj-wechat.oss-cn-beijing.aliyuncs.com/xcx_images/xcx_v3/commonality/icon_sskz_skfs_1.png') no-repeat center 0px;
  503. background-size: 100%;
  504. color: #FFFFFF;
  505. }
  506. .broadcast_m_t_back_b {
  507. background: url('https://zj-wechat.oss-cn-beijing.aliyuncs.com/xcx_images/xcx_v3/commonality/icon_sskz_azsh_1.png') no-repeat center 0px;
  508. background-size: 100%;
  509. color: #0183FA;
  510. }
  511. }
  512. /* 疏散按钮 */
  513. .evacuation-button-box {
  514. width: 650px;
  515. height: 100px;
  516. background: #0183FA;
  517. color: #fff;
  518. text-align:center;
  519. line-height: 100px;
  520. font-size: 28px;
  521. margin: 88px auto 0;
  522. border-radius: 20px;
  523. }
  524. }
  525. }
  526. </style>