Browse Source

feat: 移动端支持语音消息

master
xsx 2 years ago
parent
commit
8cc3ed9352
  1. 62
      im-uniapp/common/recorder-app.js
  2. 54
      im-uniapp/common/recorder-h5.js
  3. 96
      im-uniapp/components/chat-message-item/chat-message-item.vue
  4. 227
      im-uniapp/components/chat-record/chat-record.vue
  5. 8
      im-uniapp/main.js
  6. 10
      im-uniapp/package.json
  7. 78
      im-uniapp/pages/chat/chat-box.vue
  8. 14
      im-uniapp/static/icon/iconfont.css
  9. BIN
      im-uniapp/static/icon/iconfont.ttf

62
im-uniapp/common/recorder-app.js

@ -0,0 +1,62 @@
import UNI_APP from '@/.env.js';
const rc = uni.getRecorderManager();
// 录音开始时间
let startTime = null;
let start = () => {
return new Promise((resolve, reject) => {
rc.onStart(() => {
startTime = new Date();
resolve()
});
rc.onError((e) => {
console.log(e);
reject(e)
})
rc.start({
format: 'mp3' // 录音格式,可选值:aac/mp3
});
})
}
let pause = () => {
rc.stop();
}
let close = () => {
rc.stop();
}
let upload = () => {
return new Promise((resolve, reject) => {
rc.onStop((wavFile, a, b) => {
uni.uploadFile({
url: UNI_APP.BASE_URL + '/file/upload',
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
filePath: wavFile.tempFilePath,
name: 'file',
success: (res) => {
const duration = (new Date().getTime() - startTime.getTime()) / 1000
const data = {
duration: Math.round(duration),
url: JSON.parse(res.data).data
}
resolve(data);
},
fail: (e) => {
reject(e);
}
})
});
})
}
export {
start,
pause,
close,
upload
}

54
im-uniapp/common/recorder-h5.js

@ -0,0 +1,54 @@
import Recorder from 'js-audio-recorder';
import UNI_APP from '@/.env.js';
let rc = null;
let start = () => {
if(rc != null){
close();
}
rc = new Recorder();
return rc.start();
}
let pause = () => {
rc.pause();
}
let close = () => {
rc.destroy();
rc = null;
}
let upload = () => {
return new Promise((resolve, reject) => {
const wavBlob = rc.getWAVBlob();
const newbolb = new Blob([wavBlob], { type: 'audio/wav'})
const name = new Date().getDate() + '.wav';
const file = new File([newbolb], name)
uni.uploadFile({
url: UNI_APP.BASE_URL + '/file/upload',
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
file: file,
name: 'file',
success: (res) => {
const data = {
duration: parseInt(rc.duration),
url: JSON.parse(res.data).data
}
resolve(data);
},
fail: (e) => {
reject(e);
}
})
})
}
export {
start,
pause,
close,
upload
}

96
im-uniapp/components/chat-message-item/chat-message-item.vue

@ -1,13 +1,16 @@
<template>
<view class="chat-msg-item">
<view class="chat-msg-tip" v-if="msgInfo.type==$enums.MESSAGE_TYPE.RECALL||msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">{{msgInfo.content}}</view>
<view class="chat-msg-tip"
v-if="msgInfo.type==$enums.MESSAGE_TYPE.RECALL||msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">
{{msgInfo.content}}</view>
<view class="chat-msg-tip" v-if="msgInfo.type==$enums.MESSAGE_TYPE.TIP_TIME">
{{$date.toTimeText(msgInfo.sendTime)}}
</view>
<view class="chat-msg-normal" v-if="msgInfo.type>=0 && msgInfo.type<10"
:class="{'chat-msg-mine':msgInfo.selfSend}">
<head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage" :name="showName" :size="80"></head-image>
<head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage"
:name="showName" :size="80"></head-image>
<view class="chat-msg-content" @longpress="onShowMenu($event)">
<view v-if="msgInfo.groupId && !msgInfo.selfSend" class="chat-msg-top">
<text>{{showName}}</text>
@ -26,7 +29,6 @@
<text title="发送失败" v-if="loadFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></text>
</view>
<view class="chat-msg-file" v-if="msgInfo.type==$enums.MESSAGE_TYPE.FILE">
<view class="chat-file-box">
<view class="chat-file-info">
@ -40,31 +42,28 @@
<text title="发送失败" v-if="loadFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></text>
</view>
<view class="chat-realtime chat-msg-text" v-if="isRTMessage"
@click="$emit('call')">
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VOICE"
class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VIDEO"
class="iconfont icon-chat-video"></text>
<view class="chat-msg-audio chat-msg-text" v-if="msgInfo.type==$enums.MESSAGE_TYPE.AUDIO"
@click="onPlayAudio()">
<text class="iconfont icon-voice-play"></text>
<text class="chat-audio-text">{{JSON.parse(msgInfo.content).duration+'"'}}</text>
<text v-if="audioPlayState=='PAUSE'" class="iconfont icon-play"></text>
<text v-if="audioPlayState=='PLAYING'" class="iconfont icon-pause"></text>
</view>
<view class="chat-realtime chat-msg-text" v-if="isRTMessage" @click="$emit('call')">
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VOICE" class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VIDEO" class="iconfont icon-chat-video"></text>
<text>{{msgInfo.content}}</text>
</view>
<view class="chat-msg-status" v-if="!isRTMessage">
<text class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status==$enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status!=$enums.MESSAGE_STATUS.READED">未读</text>
</view>
<view class="chat-receipt" v-show="msgInfo.receipt" @click="onShowReadedBox">
<text v-if="msgInfo.receiptOk" class="tool-icon iconfont icon-ok"></text>
<text v-else>{{msgInfo.readedCount}}人已读</text>
</view>
<!--
<view class="chat-msg-voice" v-if="msgInfo.type==$enums.MESSAGE_TYPE.AUDIO" @click="onPlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio>
</view>
-->
</view>
</view>
</view>
@ -97,6 +96,7 @@
data() {
return {
audioPlayState: 'STOP',
innerAudioContext: null,
menu: {
show: false,
style: ""
@ -135,13 +135,27 @@
icon: "none"
})
},
onPlayVoice() {
if (!this.audio) {
this.audio = new Audio();
onPlayAudio() {
//
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
let url = JSON.parse(this.msgInfo.content).url
this.innerAudioContext.src = url;
this.innerAudioContext.onEnded((e) => {
console.log('停止')
this.audioPlayState = "STOP"
})
}
if (this.audioPlayState == 'STOP') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING";
} else if (this.audioPlayState == 'PLAYING') {
this.innerAudioContext.pause();
this.audioPlayState = "PAUSE"
} else if (this.audioPlayState == 'PAUSE') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING"
}
this.audio.src = JSON.parse(this.msgInfo.content).url;
this.audio.play();
this.handlePlayVoice = 'RUNNING';
},
onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);
@ -357,12 +371,24 @@
}
.chat-msg-audio {
display: flex;
align-items: center;
.chat-audio-text {
padding-right: 8px;
}
.icon-voice-play {
font-size: 20px;
padding-right: 8px;
}
}
.chat-realtime {
display: flex;
align-items: center;
.iconfont {
font-size: 20px;
padding-right: 8px;
@ -373,13 +399,13 @@
display: block;
.chat-readed {
font-size: 10px;
color: #ccc;
font-size: 12px;
color: #888;
font-weight: 600;
}
.chat-unread {
font-size: 10px;
font-size: 12px;
color: #f23c0f;
font-weight: 600;
}
@ -436,14 +462,28 @@
.chat-msg-file {
flex-direction: row-reverse;
}
.chat-msg-audio {
flex-direction: row-reverse;
.chat-audio-text {
padding-right: 0;
padding-left: 8px;
}
.icon-voice-play {
transform: rotateY(180deg);
}
}
.chat-realtime {
display: flex;
flex-direction: row-reverse;
.iconfont {
transform: rotateY(180deg);
}
}
}
}
}

227
im-uniapp/components/chat-record/chat-record.vue

@ -0,0 +1,227 @@
<template>
<view class="chat-record">
<view class="chat-record-bar" id="chat-record-bar" :style="recordBarStyle" @touchstart="onStartRecord"
@touchmove="onTouchMove" @touchend.prevent="onEndRecord">{{recording?'正在录音':'长按 说话'}}</view>
<view v-if="recording" class="chat-record-window" :style="recordWindowStyle">
<view class="rc-wave">
<text class="note" style="--d: 0"></text>
<text class="note" style="--d: 1"></text>
<text class="note" style="--d: 2"></text>
<text class="note" style="--d: 3"></text>
<text class="note" style="--d: 4"></text>
<text class="note" style="--d: 5"></text>
<text class="note" style="--d: 6"></text>
</view>
<view class="rc-tip">{{recordTip}}</view>
<view class="cancel-btn">
<uni-icons :class="moveToCancel?'red':'black'" type="clear"
:size="moveToCancel?45:40"></uni-icons>
</view>
<view class="opt-tip" :class="moveToCancel?'red':'black'">{{moveToCancel? '松手取消':'松手发送,上划取消'}}</view>
</view>
</view>
</template>
<script>
export default {
name: "chat-record",
data() {
return {
recording: false,
moveToCancel: false,
recordBarTop: 0,
druation: 0,
rcTimer: null
}
},
methods: {
onTouchMove(e) {
const moveY = e.touches[0].clientY;
this.moveToCancel = moveY < this.recordBarTop-40;
},
onStartRecord() {
console.log("开始录音")
this.moveToCancel = false;
this.initRecordBar();
this.$rc.start().then(() => {
this.recording = true;
console.log("开始录音成功")
//
this.startTimer();
}).catch((e) => {
console.log("录音失败"+JSON.stringify(e))
uni.showToast({
title: "录音失败",
icon: "none"
});
});
},
onEndRecord() {
this.recording = false;
//
this.$rc.pause();
//
this.StopTimer();
//
if (this.moveToCancel) {
this.$rc.close();
console.log("录音取消")
return;
}
// 1
if (this.druation == 0) {
uni.showToast({
title: "说话时间太短",
icon: 'none'
})
this.$rc.close();
return;
}
this.$rc.upload().then((data) => {
this.$emit("send", data);
}).catch((e) => {
uni.showToast({
title: e,
icon: 'none'
})
}).finally(() => {
this.$rc.close();
console.log("录音完成")
})
},
startTimer() {
this.druation = 0;
this.StopTimer();
this.rcTimer = setInterval(() => {
this.druation++;
// 60s,
if(this.druation >= 60 ){
this.onEndRecord();
}
}, 1000)
},
StopTimer() {
this.rcTimer && clearInterval(this.rcTimer);
this.rcTimer = null;
},
initRecordBar() {
const query = uni.createSelectorQuery().in(this);
query.select('#chat-record-bar').boundingClientRect((rect) => {
//
this.recordBarTop = rect.top;
}).exec()
}
},
computed: {
recordWindowStyle() {
const windowHeight = uni.getSystemInfoSync().windowHeight;
const bottom = windowHeight - this.recordBarTop + 12;
return `bottom:${bottom}px;`
},
recordBarStyle() {
const bgColor = this.recording ? "royalblue" : "#f8f8f8";
return `background-color:${bgColor};`
},
recordTip(){
if(this.druation > 50){
return `${60-this.druation}s后将停止录音`;
}
return `录音时长:${this.druation}s`;
}
}
}
</script>
<style lang="scss" scoped>
.chat-record {
.rc-wave {
display: flex;
align-items: flex-end;
justify-content: center;
position: relative;
height: 80rpx;
.note {
background: linear-gradient(to top, #395ff3 0%, #89aff3 100%);
width: 4px;
height: 50%;
border-radius: 5rpx;
margin-right: 4px;
animation: loading 0.5s infinite linear;
animation-delay: calc(0.1s * var(--d));
@keyframes loading {
0% {
background-image: linear-gradient(to right, #395ff3 0%, #89aff3 100%);
height: 20%;
border-radius: 5rpx;
}
50% {
background-image: linear-gradient(to top, #395ff3 0%, #a9cff3 100%);
height: 80%;
border-radius: 5rpx;
}
100% {
background-image: linear-gradient(to top, #395ff3 0%, #a9cff3 100%);
height: 20%;
border-radius: 5rpx;
}
}
}
}
.chat-record-bar {
padding: 10rpx;
margin: 10rpx;
border-radius: 10rpx;
text-align: center;
}
.chat-record-window {
position: fixed;
left: 0;
height: 360rpx;
width: 100%;
background-color: rgba(255, 255, 255, 0.95);
padding: 30rpx;
.icon-microphone {
text-align: center;
font-size: 80rpx;
padding: 10rpx;
}
.rc-tip {
text-align: center;
font-size: 30rpx;
margin-top: 20rpx;
}
.cancel-btn {
text-align: center;
margin-top: 40rpx;
height: 80rpx;
}
.opt-tip {
text-align: center;
font-size: 30rpx;
padding: 20rpx;
}
.red {
color: red !important;
}
.black {
color: gray;
}
}
}
</style>

8
im-uniapp/main.js

@ -6,6 +6,13 @@ import * as date from './common/date';
import * as socketApi from './common/wssocket';
import store from './store';
import { createSSRApp } from 'vue'
// #ifdef H5
import * as recorder from './common/recorder-h5';
// #endif
// #ifndef H5
import * as recorder from './common/recorder-app';
// #endif
export function createApp() {
const app = createSSRApp(App)
@ -15,6 +22,7 @@ export function createApp() {
app.config.globalProperties.$emo = emotion;
app.config.globalProperties.$enums = enums;
app.config.globalProperties.$date = date;
app.config.globalProperties.$rc = recorder;
return {
app
}

10
im-uniapp/package.json

@ -1,6 +1,10 @@
{
"name": "盒子IM",
"uni-app": {
"scripts": {
}
"scripts": {}
},
"dependencies": {
"js-audio-recorder": "^1.0.7",
"recorder-core": "^1.3.23122400"
}
}
}

78
im-uniapp/pages/chat/chat-box.vue

@ -9,8 +9,7 @@
<scroll-view class="scroll-box" scroll-y="true" @scrolltoupper="onScrollToTop"
:scroll-into-view="'chat-item-'+scrollMsgIdx">
<view v-for="(msgInfo,idx) in chat.messages" :key="idx">
<chat-message-item v-if="idx>=showMinIdx" :headImage="headImage(msgInfo)"
@call="onRtCall(msgInfo)"
<chat-message-item v-if="idx>=showMinIdx" :headImage="headImage(msgInfo)" @call="onRtCall(msgInfo)"
:showName="showName(msgInfo)" @recall="onRecallMessage" @delete="onDeleteMessage"
@longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile" :id="'chat-item-'+idx"
:msgInfo="msgInfo" :groupMembers="groupMembers">
@ -29,7 +28,10 @@
</scroll-view>
</view>
<view class="send-bar">
<view class="send-text">
<view v-if="!showRecord" class="iconfont icon-voice-circle" @click="onVoiceInput()"></view>
<view v-else class="iconfont icon-keyboard" @click="onKeyboardInput()"></view>
<chat-record v-if="showRecord" class="chat-record" @send="onSendRecord" ></chat-record>
<view v-else class="send-text">
<textarea class="send-text-area" v-model="sendText" auto-height :show-confirm-bar="false"
:placeholder="isReceipt?'[回执消息]':''" :adjust-position="false" @confirm="sendTextMessage()"
@keyboardheightchange="onKeyboardheightchange" @input="onTextInput" confirm-type="send" confirm-hold
@ -69,19 +71,19 @@
<view class="tool-name">文件</view>
</view>
<!-- #endif -->
<view class="chat-tools-item" @click="showTip()">
<view class="chat-tools-item" @click="onVoiceInput()">
<view class="tool-icon iconfont icon-microphone"></view>
<view class="tool-name">语音输入</view>
</view>
<view class="tool-name">语音消息</view>
</view>
<view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="switchReceipt()">
<view class="tool-icon iconfont icon-receipt" :class="isReceipt?'active':''"></view>
<view class="tool-name">回执消息</view>
</view>
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVideoCall()">
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVideoCall()">
<view class="tool-icon iconfont icon-video"></view>
<view class="tool-name">视频通话</view>
</view>
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVoiceCall()">
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVoiceCall()">
<view class="tool-icon iconfont icon-call"></view>
<view class="tool-name">语音通话</view>
</view>
@ -101,6 +103,7 @@
</template>
<script>
import UNI_APP from '@/.env.js';
export default {
data() {
return {
@ -110,36 +113,68 @@
groupMembers: [],
sendText: "",
isReceipt: false, //
showVoice: false, //
scrollMsgIdx: 0, //
chatTabBox: 'none',
showKeyBoard: false,
showRecord: false,
keyboardHeight: 322,
atUserIds: [],
recordText: "",
showMinIdx: 0 // showMinIdx
}
},
methods: {
showTip() {
uni.showToast({
title: "暂未支持...",
icon: "none"
onVoiceInput() {
this.showRecord = true;
this.switchChatTabBox('none',true);
},
onKeyboardInput() {
this.showRecord = false;
this.switchChatTabBox('none',false);
},
onSendRecord(data) {
console.log(data);
let msgInfo = {
content: JSON.stringify(data),
type: this.$enums.MESSAGE_TYPE.AUDIO,
receipt: this.isReceipt
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
this.$http({
url: this.messageAction,
method: 'POST',
data: msgInfo
}).then((id) => {
msgInfo.id = id;
msgInfo.sendTime = new Date().getTime();
msgInfo.sendId = this.$store.state.userStore.userInfo.id;
msgInfo.selfSend = true;
msgInfo.status = this.$enums.MESSAGE_STATUS.UNSEND;
msgInfo.readedCount = 0;
this.$store.commit("insertMessage", msgInfo);
//
this.moveChatToTop();
//
this.scrollToBottom();
this.isReceipt = false;
})
},
onRtCall(msgInfo){
if(msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE){
onRtCall(msgInfo) {
if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE) {
this.onVoiceCall();
}else if(msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO){
} else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO) {
this.onVideoCall();
}
},
onVideoCall(){
onVideoCall() {
const friendInfo = encodeURIComponent(JSON.stringify(this.friend));
uni.navigateTo({
url: `/pages/chat/chat-video?mode=video&friend=${friendInfo}&isHost=true`
})
},
onVoiceCall(){
onVoiceCall() {
const friendInfo = encodeURIComponent(JSON.stringify(this.friend));
uni.navigateTo({
url: `/pages/chat/chat-video?mode=voice&friend=${friendInfo}&isHost=true`
@ -721,6 +756,11 @@
margin: 3rpx;
}
.chat-record {
flex: 1;
}
.send-text {
flex: 1;
background-color: #f8f8f8 !important;
@ -728,8 +768,6 @@
padding: 20rpx;
background-color: #fff;
border-radius: 20rpx;
max-height: 300rpx;
min-height: 85rpx;
font-size: 30rpx;
box-sizing: border-box;

14
im-uniapp/static/icon/iconfont.css

@ -1,6 +1,6 @@
@font-face {
font-family: "iconfont"; /* Project id 4272106 */
src: url('iconfont.ttf?t=1710556421604') format('truetype');
src: url('iconfont.ttf?t=1711870080646') format('truetype');
}
.iconfont {
@ -11,6 +11,18 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-pause:before {
content: "\e669";
}
.icon-play:before {
content: "\e620";
}
.icon-voice-play:before {
content: "\e675";
}
.icon-chat-video:before {
content: "\e73b";
}

BIN
im-uniapp/static/icon/iconfont.ttf

Binary file not shown.
Loading…
Cancel
Save