message-input-audio.vue 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. <template>
  2. <div
  3. :class="{
  4. 'message-input-audio': true,
  5. 'message-input-audio-open': isAudioTouchBarShow,
  6. }"
  7. >
  8. <Icon
  9. class="audio-message-icon"
  10. :file="audioIcon"
  11. @onClick="switchAudio"
  12. />
  13. <view
  14. v-if="props.isEnableAudio"
  15. class="tui-message-input-main"
  16. @touchstart="handleTouchStart"
  17. @longpress="handleLongPress"
  18. @touchmove="handleTouchMove"
  19. @touchend="handleTouchEnd"
  20. >
  21. <span>{{ TUITranslateService.t(`TUIChat.${touchBarText}`) }}</span>
  22. <view
  23. v-if="isRecording"
  24. class="record-modal"
  25. >
  26. <div class="red-mask" />
  27. <view class="float-element moving-slider" />
  28. <view class="float-element modal-title">
  29. {{ TUITranslateService.t(`TUIChat.${modalText}`) }}
  30. </view>
  31. </view>
  32. </view>
  33. </div>
  34. </template>
  35. <script setup lang="ts">
  36. import { ref, onMounted, onUnmounted } from '../../../adapter-vue';
  37. import {
  38. TUIStore,
  39. StoreName,
  40. TUIChatService,
  41. SendMessageParams,
  42. IConversationModel,
  43. TUITranslateService,
  44. } from '@tencentcloud/chat-uikit-engine';
  45. import { TUIGlobal } from '@tencentcloud/universal-api';
  46. import throttle from 'lodash/throttle';
  47. import Icon from '../../common/Icon.vue';
  48. import audioIcon from '../../../assets/icon/audio.svg';
  49. import { Toast, TOAST_TYPE } from '../../common/Toast/index';
  50. import { isEnabledMessageReadReceiptGlobal } from '../utils/utils';
  51. import { InputDisplayType } from '../../../interface';
  52. interface IProps {
  53. isEnableAudio: boolean;
  54. }
  55. interface IEmits {
  56. (e: 'changeDisplayType', type: InputDisplayType): void;
  57. }
  58. interface RecordResult {
  59. tempFilePath: string;
  60. duration?: number;
  61. fileSize?: number;
  62. }
  63. type TouchBarText = '按住说话' | '抬起发送' | '抬起取消';
  64. type ModalText = '正在录音' | '继续上滑可取消' | '松开手指 取消发送';
  65. const emits = defineEmits<IEmits>();
  66. const props = withDefaults(defineProps<IProps>(), {
  67. isEnableAudio: false,
  68. });
  69. let recordTime: number = 0;
  70. let isManualCancelBySlide = false;
  71. let recordTimer: number | undefined;
  72. let firstTouchPageY: number = -1;
  73. let isFingerTouchingScreen = false;
  74. let isFirstAuthrizedRecord = false;
  75. const recorderManager = TUIGlobal?.getRecorderManager();
  76. const isRecording = ref(false);
  77. const touchBarText = ref<TouchBarText>('按住说话');
  78. const modalText = ref<ModalText>('正在录音');
  79. const isAudioTouchBarShow = ref<boolean>(false);
  80. const currentConversation = ref<IConversationModel>();
  81. const recordConfig = {
  82. // 录音的时长,单位 ms,最大值 600000(10 分钟)
  83. duration: 60000,
  84. // 采样率
  85. sampleRate: 44100,
  86. // 录音通道数
  87. numberOfChannels: 1,
  88. // 编码码率
  89. encodeBitRate: 192000,
  90. // 音频格式,选择此格式创建的音频消息,可以在即时通信 IM 全平台(Android、iOS、微信小程序和Web)互通
  91. format: 'mp3',
  92. };
  93. function switchAudio() {
  94. emits('changeDisplayType', props.isEnableAudio ? 'editor' : 'audio');
  95. }
  96. onMounted(() => {
  97. // 为声音录制管理器注册事件
  98. recorderManager.onStart(onRecorderStart);
  99. recorderManager.onStop(onRecorderStop);
  100. recorderManager.onError(onRecorderError);
  101. TUIStore.watch(StoreName.CONV, {
  102. currentConversation: onCurrentConverstaionUpdated,
  103. });
  104. });
  105. onUnmounted(() => {
  106. TUIStore.unwatch(StoreName.CONV, {
  107. currentConversation: onCurrentConverstaionUpdated,
  108. });
  109. });
  110. function onCurrentConverstaionUpdated(conversation: IConversationModel) {
  111. currentConversation.value = conversation;
  112. }
  113. function initRecorder() {
  114. initRecorderData();
  115. initRecorderView();
  116. }
  117. function initRecorderView() {
  118. isRecording.value = false;
  119. touchBarText.value = '按住说话';
  120. modalText.value = '正在录音';
  121. }
  122. function initRecorderData(options?: { hasError: boolean }) {
  123. clearInterval(recordTimer);
  124. recordTimer = undefined;
  125. recordTime = 0;
  126. firstTouchPageY = -1;
  127. isManualCancelBySlide = false;
  128. if (!options?.hasError) {
  129. recorderManager.stop();
  130. }
  131. }
  132. function handleTouchStart() {
  133. if (isFingerTouchingScreen) {
  134. // 兼容 APP 首次由于用户授权产生的录音需要忽略
  135. isFirstAuthrizedRecord = true;
  136. }
  137. }
  138. function handleLongPress() {
  139. isFingerTouchingScreen = true;
  140. recorderManager.start(recordConfig);
  141. }
  142. const handleTouchMove = throttle(function (e) {
  143. if (isRecording.value) {
  144. const pageY = e.changedTouches[e.changedTouches.length - 1].pageY;
  145. if (firstTouchPageY < 0) {
  146. firstTouchPageY = pageY;
  147. }
  148. const offset = (firstTouchPageY as number) - pageY;
  149. // 录音时的手势上划移动距离对应文案变化
  150. if (offset > 150) {
  151. touchBarText.value = '抬起取消';
  152. modalText.value = '松开手指 取消发送';
  153. isManualCancelBySlide = true;
  154. } else if (offset > 50) {
  155. touchBarText.value = '抬起发送';
  156. modalText.value = '继续上滑可取消';
  157. isManualCancelBySlide = false;
  158. } else {
  159. touchBarText.value = '抬起发送';
  160. modalText.value = '正在录音';
  161. isManualCancelBySlide = false;
  162. }
  163. }
  164. }, 100);
  165. // 手指离开页面滑动
  166. function handleTouchEnd() {
  167. isFingerTouchingScreen = false;
  168. recorderManager.stop();
  169. }
  170. function onRecorderStart() {
  171. if (!isFingerTouchingScreen) {
  172. // 如果开始录音但手指离开屏幕 说明是首次授权弹窗打断了录音 需要忽略
  173. isFirstAuthrizedRecord = true;
  174. recorderManager.stop();
  175. return;
  176. }
  177. recordTimer = setInterval(() => {
  178. recordTime += 1;
  179. }, 1000);
  180. touchBarText.value = '抬起发送';
  181. isRecording.value = true;
  182. }
  183. function onRecorderStop(res: RecordResult) {
  184. if (isFirstAuthrizedRecord) {
  185. // 兼容微信首次由于用户授权产生的录音需要忽略 对 APP 无效
  186. isFirstAuthrizedRecord = false;
  187. initRecorder();
  188. return;
  189. }
  190. if (isManualCancelBySlide || !isRecording.value) {
  191. initRecorder();
  192. return;
  193. }
  194. clearInterval(recordTimer);
  195. /**
  196. * 兼容 uniapp 打包 app
  197. * 兼容 uniapp 语音消息没有 duration
  198. * duration 和 fileSize 需要用户自己补充
  199. * 文件大小 = (音频码率) * 时间长度(单位:秒) / 8
  200. * res.tempFilePath 存储录音文件的临时路径
  201. */
  202. const tempFilePath = res.tempFilePath;
  203. const duration = res.duration ? res.duration : recordTime * 1000;
  204. const fileSize = res.fileSize ? res.fileSize : ((48 * recordTime) / 8) * 1024;
  205. if (duration < 1000) {
  206. Toast({
  207. message: '录音时间太短',
  208. type: TOAST_TYPE.NORMAL,
  209. duration: 1500,
  210. });
  211. } else {
  212. const options = {
  213. to:
  214. currentConversation?.value?.groupProfile?.groupID
  215. || currentConversation?.value?.userProfile?.userID,
  216. conversationType: currentConversation?.value?.type,
  217. payload: { file: { duration, tempFilePath, fileSize } },
  218. needReadReceipt: isEnabledMessageReadReceiptGlobal(),
  219. } as SendMessageParams;
  220. TUIChatService?.sendAudioMessage(options);
  221. }
  222. initRecorder();
  223. }
  224. function onRecorderError() {
  225. initRecorderData({ hasError: true });
  226. initRecorderView();
  227. }
  228. </script>
  229. <style lang="scss" scoped>
  230. @import "../../../assets/styles/common";
  231. .message-input-audio {
  232. display: flex;
  233. flex-direction: row;
  234. .audio-message-icon {
  235. width: 23px;
  236. height: 23px;
  237. justify-items: center;
  238. padding: 7px 7px 7px 0;
  239. }
  240. .tui-message-input-main {
  241. flex: 1;
  242. border-radius: 9.4px;
  243. display: flex;
  244. flex-direction: row;
  245. justify-content: center;
  246. align-items: center;
  247. background-color: #fff;
  248. .record-modal {
  249. height: 300rpx;
  250. width: 60vw;
  251. background-color: rgba(0, 0, 0, 0.8);
  252. position: fixed;
  253. left: 50%;
  254. top: 50%;
  255. transform: translate(-50%, -50%);
  256. z-index: 9999;
  257. border-radius: 24rpx;
  258. display: flex;
  259. flex-direction: column;
  260. overflow: hidden;
  261. .red-mask {
  262. position: absolute;
  263. inset: 0;
  264. background-color: rgba(#ff3e48, 0.5);
  265. opacity: 0;
  266. transition: opacity 10ms linear;
  267. z-index: 1;
  268. }
  269. .moving-slider {
  270. margin: 10vw;
  271. width: 40rpx;
  272. height: 16rpx;
  273. border-radius: 4rpx;
  274. background-color: #006fff;
  275. animation: loading 1s ease-in-out infinite alternate;
  276. z-index: 2;
  277. }
  278. .float-element {
  279. position: relative;
  280. z-index: 2;
  281. }
  282. }
  283. @keyframes loading {
  284. 0% {
  285. transform: translate(0, 0);
  286. }
  287. 100% {
  288. transform: translate(30vw, 0);
  289. background-color: #f5634a;
  290. width: 40px;
  291. }
  292. }
  293. .modal-title {
  294. text-align: center;
  295. color: #fff;
  296. }
  297. }
  298. &-open {
  299. flex: 1;
  300. }
  301. }
  302. </style>