message-input-audio.vue 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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. :size="'23px'"
  12. :hotAreaSize="'3px'"
  13. @onClick="switchAudio"
  14. />
  15. <view
  16. v-if="props.isEnableAudio"
  17. class="audio-input-touch-bar"
  18. @touchstart="handleTouchStart"
  19. @longpress="handleLongPress"
  20. @touchmove="handleTouchMove"
  21. @touchend="handleTouchEnd"
  22. >
  23. <span>{{ TUITranslateService.t(`TUIChat.${touchBarText}`) }}</span>
  24. <view
  25. v-if="isRecording"
  26. class="record-modal"
  27. >
  28. <div class="red-mask" />
  29. <view class="float-element moving-slider" />
  30. <view class="float-element modal-title">
  31. {{ TUITranslateService.t(`TUIChat.${modalText}`) }}
  32. </view>
  33. </view>
  34. </view>
  35. </div>
  36. </template>
  37. <script setup lang="ts">
  38. import { ref, onMounted, onUnmounted } from '../../../adapter-vue';
  39. import {
  40. TUIStore,
  41. StoreName,
  42. TUIChatService,
  43. SendMessageParams,
  44. IConversationModel,
  45. TUITranslateService,
  46. } from '@tencentcloud/chat-uikit-engine';
  47. import { TUIGlobal } from '@tencentcloud/universal-api';
  48. import { throttle } from 'lodash';
  49. import Icon from '../../common/Icon.vue';
  50. import audioIcon from '../../../assets/icon/audio.svg';
  51. import { Toast, TOAST_TYPE } from '../../common/Toast/index';
  52. import { isEnabledMessageReadReceiptGlobal } from '../utils/utils';
  53. import { InputDisplayType } from '../../../interface';
  54. interface IProps {
  55. isEnableAudio: boolean;
  56. }
  57. interface IEmits {
  58. (e: 'changeDisplayType', type: InputDisplayType): void;
  59. }
  60. interface RecordResult {
  61. tempFilePath: string;
  62. duration?: number;
  63. fileSize?: number;
  64. }
  65. type TouchBarText = '按住说话' | '抬起发送' | '抬起取消';
  66. type ModalText = '正在录音' | '继续上滑可取消' | '松开手指 取消发送';
  67. const emits = defineEmits<IEmits>();
  68. const props = withDefaults(defineProps<IProps>(), {
  69. isEnableAudio: false,
  70. });
  71. let recordTime: number = 0;
  72. let isManualCancelBySlide = false;
  73. let recordTimer: number | undefined;
  74. let firstTouchPageY: number = -1;
  75. let isFingerTouchingScreen = false;
  76. let isFirstAuthrizedRecord = false;
  77. const recorderManager = TUIGlobal?.getRecorderManager();
  78. const isRecording = ref(false);
  79. const touchBarText = ref<TouchBarText>('按住说话');
  80. const modalText = ref<ModalText>('正在录音');
  81. const isAudioTouchBarShow = ref<boolean>(false);
  82. const currentConversation = ref<IConversationModel>();
  83. const recordConfig = {
  84. // Duration of the recording, in ms, with a maximum value of 600000 (10 minutes)
  85. duration: 60000,
  86. // Sampling rate
  87. sampleRate: 44100,
  88. // Number of recording channels
  89. numberOfChannels: 1,
  90. // Encoding bit rate
  91. encodeBitRate: 192000,
  92. // Audio format
  93. // Select this format to create audio messages that can be interoperable across all chat platforms (Android, iOS, WeChat Mini Programs, and Web).
  94. format: 'mp3',
  95. };
  96. function switchAudio() {
  97. emits('changeDisplayType', props.isEnableAudio ? 'editor' : 'audio');
  98. }
  99. onMounted(() => {
  100. // Register events for the audio recording manager
  101. recorderManager.onStart(onRecorderStart);
  102. recorderManager.onStop(onRecorderStop);
  103. recorderManager.onError(onRecorderError);
  104. TUIStore.watch(StoreName.CONV, {
  105. currentConversation: onCurrentConverstaionUpdated,
  106. });
  107. });
  108. onUnmounted(() => {
  109. TUIStore.unwatch(StoreName.CONV, {
  110. currentConversation: onCurrentConverstaionUpdated,
  111. });
  112. });
  113. function onCurrentConverstaionUpdated(conversation: IConversationModel) {
  114. currentConversation.value = conversation;
  115. }
  116. function initRecorder() {
  117. initRecorderData();
  118. initRecorderView();
  119. }
  120. function initRecorderView() {
  121. isRecording.value = false;
  122. touchBarText.value = '按住说话';
  123. modalText.value = '正在录音';
  124. }
  125. function initRecorderData(options?: { hasError: boolean }) {
  126. clearInterval(recordTimer);
  127. recordTimer = undefined;
  128. recordTime = 0;
  129. firstTouchPageY = -1;
  130. isManualCancelBySlide = false;
  131. if (!options?.hasError) {
  132. recorderManager.stop();
  133. }
  134. }
  135. function handleTouchStart() {
  136. if (isFingerTouchingScreen) {
  137. // Compatibility: Ignore the recording generated by the user's first authorization on the APP.
  138. isFirstAuthrizedRecord = true;
  139. }
  140. }
  141. function handleLongPress() {
  142. isFingerTouchingScreen = true;
  143. recorderManager.start(recordConfig);
  144. }
  145. const handleTouchMove = throttle(function (e) {
  146. if (isRecording.value) {
  147. const pageY = e.changedTouches[e.changedTouches.length - 1].pageY;
  148. if (firstTouchPageY < 0) {
  149. firstTouchPageY = pageY;
  150. }
  151. const offset = (firstTouchPageY as number) - pageY;
  152. if (offset > 150) {
  153. touchBarText.value = '抬起取消';
  154. modalText.value = '松开手指 取消发送';
  155. isManualCancelBySlide = true;
  156. } else if (offset > 50) {
  157. touchBarText.value = '抬起发送';
  158. modalText.value = '继续上滑可取消';
  159. isManualCancelBySlide = false;
  160. } else {
  161. touchBarText.value = '抬起发送';
  162. modalText.value = '正在录音';
  163. isManualCancelBySlide = false;
  164. }
  165. }
  166. }, 100);
  167. function handleTouchEnd() {
  168. isFingerTouchingScreen = false;
  169. recorderManager.stop();
  170. }
  171. function onRecorderStart() {
  172. if (!isFingerTouchingScreen) {
  173. // If recording starts but the finger leaves the screen,
  174. // it means that the initial authorization popup interrupted the recording and it should be ignored.
  175. isFirstAuthrizedRecord = true;
  176. recorderManager.stop();
  177. return;
  178. }
  179. recordTimer = setInterval(() => {
  180. recordTime += 1;
  181. }, 1000);
  182. touchBarText.value = '抬起发送';
  183. isRecording.value = true;
  184. }
  185. function onRecorderStop(res: RecordResult) {
  186. if (isFirstAuthrizedRecord) {
  187. // Compatibility: Ignore the recording generated by the user's first authorization on WeChat. This is not applicable to the APP.
  188. isFirstAuthrizedRecord = false;
  189. initRecorder();
  190. return;
  191. }
  192. if (isManualCancelBySlide || !isRecording.value) {
  193. initRecorder();
  194. return;
  195. }
  196. clearInterval(recordTimer);
  197. /**
  198. * Compatible with uniapp for building apps
  199. * Compatible with uniapp voice messages without duration
  200. * Duration and fileSize need to be supplemented by the user
  201. * File size = (Audio bitrate) * Length of time (in seconds) / 8
  202. * res.tempFilePath stores the temporary path of the recorded audio file
  203. */
  204. const tempFilePath = res.tempFilePath;
  205. const duration = res.duration ? res.duration : recordTime * 1000;
  206. const fileSize = res.fileSize ? res.fileSize : ((48 * recordTime) / 8) * 1024;
  207. if (duration < 1000) {
  208. Toast({
  209. message: '录音时间太短',
  210. type: TOAST_TYPE.NORMAL,
  211. duration: 1500,
  212. });
  213. } else {
  214. const options = {
  215. to:
  216. currentConversation?.value?.groupProfile?.groupID
  217. || currentConversation?.value?.userProfile?.userID,
  218. conversationType: currentConversation?.value?.type,
  219. payload: { file: { duration, tempFilePath, fileSize } },
  220. needReadReceipt: isEnabledMessageReadReceiptGlobal(),
  221. } as SendMessageParams;
  222. TUIChatService?.sendAudioMessage(options);
  223. }
  224. initRecorder();
  225. }
  226. function onRecorderError() {
  227. initRecorderData({ hasError: true });
  228. initRecorderView();
  229. }
  230. </script>
  231. <style lang="scss" scoped>
  232. @import "../../../assets/styles/common";
  233. .message-input-audio {
  234. display: flex;
  235. flex-direction: row;
  236. align-items: center;
  237. .audio-message-icon {
  238. margin-right: 3px;
  239. }
  240. .audio-input-touch-bar {
  241. height: 39px;
  242. flex: 1;
  243. border-radius: 10px;
  244. display: flex;
  245. flex-direction: row;
  246. justify-content: center;
  247. align-items: center;
  248. background-color: #fff;
  249. .record-modal {
  250. height: 300rpx;
  251. width: 60vw;
  252. background-color: rgba(0, 0, 0, 0.8);
  253. position: fixed;
  254. left: 50%;
  255. top: 50%;
  256. transform: translate(-50%, -50%);
  257. z-index: 9999;
  258. border-radius: 24rpx;
  259. display: flex;
  260. flex-direction: column;
  261. overflow: hidden;
  262. .red-mask {
  263. position: absolute;
  264. inset: 0;
  265. background-color: rgba(#ff3e48, 0.5);
  266. opacity: 0;
  267. transition: opacity 10ms linear;
  268. z-index: 1;
  269. }
  270. .moving-slider {
  271. margin: 10vw;
  272. width: 40rpx;
  273. height: 16rpx;
  274. border-radius: 4rpx;
  275. background-color: #006fff;
  276. animation: loading 1s ease-in-out infinite alternate;
  277. z-index: 2;
  278. }
  279. .float-element {
  280. position: relative;
  281. z-index: 2;
  282. }
  283. }
  284. @keyframes loading {
  285. 0% {
  286. transform: translate(0, 0);
  287. }
  288. 100% {
  289. transform: translate(30vw, 0);
  290. background-color: #f5634a;
  291. width: 40px;
  292. }
  293. }
  294. .modal-title {
  295. text-align: center;
  296. color: #fff;
  297. }
  298. }
  299. &-open {
  300. flex: 1;
  301. }
  302. }
  303. </style>