Ai_GirlFriend/xuniYou/pages/chat/phone.vue
2026-01-31 19:15:41 +08:00

862 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view>
<view class="body">
<uni-nav-bar fixed statusBar left-icon="left" background-color="transparent" :border="false" @clickLeft="back"
color="#ffffff"></uni-nav-bar>
<image class="back"
:src="loverBasicList.image_url ? loverBasicList.image_url : 'https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/39c6f8899c15f60fc59207835f95e07a.png'"
mode="aspectFill"></image>
</view>
<view class="header">
<view class="header_content">
<view class="header_time">通话剩余时间</view>
<view class="header_module fa sb">
<view class="header_title faj">00</view>
<view class="header_title faj">05</view>
<view class="header_title faj">00</view>
</view>
<view class="header_recharge faj">充值<image src="/static/images/phone_more.png" mode="widthFix"></image>
</view>
</view>
</view>
<!-- 替换为动态脑电波效果 -->
<view class="dynamic-wave">
<view class="wave-bar" v-for="n in 15" :key="n" :style="{ 'animation-delay': n * 0.1 + 's' }"></view>
</view>
<view class="opt fa sb">
<view @click="hangUp()" class="opt_item">
<image class="opt_image" src="/static/images/phone_a2.png" mode="widthFix"></image>
<view class="opt_name">挂断</view>
</view>
<view class="opt_item" @click="openMicPermission()">
<image class="opt_image" src="/static/images/phone_a1.png" mode="widthFix"></image>
<view class="opt_name">麦克风权限</view>
</view>
</view>
<view class="black"></view>
</view>
</template>
<script>
// #ifdef APP
const recorder = uni.requireNativePlugin('AudioRecode')
// const recorder = uni.requireNativePlugin('LcPrinter')
console.log('recorder123456::', recorder)
// #endif
// let socketTask = null
// socketTask = uni.connectSocket({
// url: 'wss://lovers.shandonghuixing.com/voice/call',//'wss://<host>/voice/call',
// header:{
// Authorization: 'Bearer ' + uni.getStorageSync("token") || "" //'Bearer <token>'
// }
// })
// console.log('socketTask:',socketTask)
import {
} from '@/utils/api.js'
import notHave from '@/components/not-have.vue';
import topSafety from '@/components/top-safety.vue';
const recorderManager = uni.getRecorderManager();
export default {
components: {
notHave,
topSafety,
},
beforeCreate() {
console.log('beforeCreate', )
// const domModule = uni.requireNativePlugin('io.dcolud.audio.recode')
// console.log('beforeCreate',domModule)
},
data() {
return {
loverBasicList: uni.getStorageSync('loverBasicList'),
socketTask: null,
isRecording: false,
status: 'Ready',
audioContext: null,
audioData: [],
isApp: false, // 是否为 App 端
}
},
onLoad() {
// 检测平台
const systemInfo = uni.getSystemInfoSync()
console.log('systemInfo', systemInfo)
// console.log('plus', plus)
this.isApp = systemInfo.uniPlatform === 'app'
this.connectWebSocket()
this.initAudio()
},
onUnload() {
this.stopCall()
},
methods: {
hangUp() {
uni.showModal({
title: '提示',
content: '挂断改通话',
success: function(res) {
if (res.confirm) {
uni.navigateBack();
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
},
initAudio() {
// 销毁旧的音频上下文(如果存在)
if (this.audioContext) {
this.audioContext.destroy();
}
// 创建新的音频上下文
this.audioContext = uni.createInnerAudioContext()
this.audioContext.volume = 1
// 设置音频源类型
this.audioContext.srcType = 'auto'
// 添加音频事件监听
this.audioContext.onError((err) => {
console.error('音频播放失败:', err)
console.error('错误详情:', JSON.stringify(err))
uni.showToast({
title: '音频播放失败',
icon: 'none'
})
})
this.audioContext.onPlay(() => {
console.log('音频开始播放')
})
this.audioContext.onEnded(() => {
console.log('音频播放结束')
})
this.audioContext.onCanplay(() => {
console.log('音频可以播放了')
})
},
connectWebSocket() {
this.socketTask = uni.connectSocket({
url: 'wss://lovers.shandonghuixing.com/voice/call',
header: {
"content-type": "application/json",
'Authorization': 'Bearer ' + uni.getStorageSync("token") || ""
},
success: () => console.log('WS 连接成功')
});
this.socketTask.onOpen((res) => {
console.log('onOpen:', res)
this.startRecording();
});
this.socketTask.onMessage((res) => {
console.log('onMessage:', res.data)
this.handleServerMessage(res.data);
});
this.socketTask.onError((err) => {
console.error('WS 错误', err);
});
this.socketTask.onClose((res) => {
console.log('关闭:', res)
if (this.isApp) {
console.log('关闭1', recorder)
recorder.stop()
}
})
},
// 开始录制
async startRecording() {
console.log('开始录制', )
if (this.isRecording) return;
// App 端不支持 onFrameRecorded
this.isRecording = true;
this.status = 'Call Started';
if (this.isApp) {
console.log('this.isApp:', this.isApp)
this.startRecord()
} else {
// 小程序和 H5 端支持 onFrameRecorded
recorderManager.onFrameRecorded((res) => {
// console.log('onFrameRecorded:', res)
const {
frameBuffer,
isLastFrame
} = res;
if (this.socketTask && this.socketTask.readyState === 1) {
this.socketTask.send({
data: frameBuffer
});
}
});
recorderManager.start({
duration: 600000,
format: 'pcm', // ⚠️ 必须用 PCMParaformer 实时版只吃 PCM
sampleRate: 16000, // ⚠️ 必须 16000Hz这是 ASR 的标准
numberOfChannels: 1, // 单声道
frameSize: 2, // 单位是 KB。设置小一点(2-4KB)延迟低,设置太大延迟高
audioSource: 'voice_communication'
});
}
},
startRecord() {
const doStart = () => {
recorder.start({
sampleRate: 16000,
frameSize: 640,
source: 'mic'
}, (res) => {
// console.log('socket---res',res)
if (res.type === 'frame') {
const ab = uni.base64ToArrayBuffer(res.data)
// console.log('ab',ab)
this.socketTask.send({
data: ab
})
}
})
}
if (uni.getSystemInfoSync().platform !== 'android') {
doStart()
return
}
if (typeof plus === 'undefined') {
// 还没到 plusready
document.addEventListener('plusready', () => {
plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e) => {
if (e.granted && e.granted.length) doStart()
else uni.showModal({
title: '权限不足',
content: '请允许麦克风权限'
})
})
})
return
}
plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e) => {
console.log('e.granted', e.granted)
if (e.granted && e.granted.length) doStart()
else uni.showModal({
title: '权限不足',
content: '请允许麦克风权限'
})
})
},
openMicPermission() {
plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e) => {
console.log('e.granted', e)
if (e.granted && e.granted.length) {
uni.showToast({
title: '麦克风权限已开启',
icon: 'none'
})
} else {
uni.showModal({
title: '权限不足',
content: '请允许麦克风权限'
})
}
})
},
stopCall() {
this.isRecording = false;
recorderManager.stop();
if (this.socketTask) {
this.socketTask.close();
}
if (this.audioContext) {
this.audioContext.pause();
this.audioContext.destroy();
}
},
// 接收消息
async handleServerMessage(data) {
console.log('接受到的信息:', data, typeof(data))
console.log('接受到的信息:', JSON.stringify(data))
console.log('接受到的信息:', Object.prototype.toString.call(data))
if (data && typeof(data) == 'object') {
//处理数据流
uni.hideLoading()
this.audioData.push(data);
console.log('this.audioData:', this.audioData)
} else {
//处理非数据流
let dataInfo = JSON.parse(data)
console.log('dataInfo:', dataInfo.type)
if (dataInfo.type == 'reply_end') {
if(this.isApp){
uni.showLoading({
title: '思考中',
mask: true
});
console.log('reply_endreply_endreply_end',)
// 创建一个完整的 ArrayBuffer 来存储所有音频数据
const totalLength = this.audioData.reduce((acc, buffer) => acc + buffer.byteLength, 0)
console.log('totalLength:',totalLength)
const mergedArrayBuffer = new Uint8Array(totalLength)
// 将音频数据填充到mergedArrayBuffer中修复之前这里没有填充数据
let offset = 0;
for (let i = 0; i < this.audioData.length; i++) {
const buffer = new Uint8Array(this.audioData[i]);
mergedArrayBuffer.set(buffer, offset);
offset += buffer.byteLength;
}
// console.log('mergedArrayBuffer填充完成长度:', mergedArrayBuffer)
const base64Audio = uni.arrayBufferToBase64(mergedArrayBuffer.buffer);
const base64WithPrefix = `data:audio/mp3;base64,${base64Audio}`;
// console.log('base64WithPrefix:',base64WithPrefix)
const filePath = await new Promise((resolve) => {
console.log('this:',this)
const fileName = `_doc/${Date.now()}_numberPerson.mp3`;
this.base64ToFile(base64WithPrefix, fileName, (path) => {
console.log('pathpathpath',path)
resolve(path);
});
});
console.log('pathpathpathfilePath',filePath)
// 使用filePath播放音频
// const audioCtx = uni.createInnerAudioContext();
this.audioContext.src = filePath;
try {
uni.hideLoading()
console.log('尝试延迟播放...');
this.audioContext.play();
// 清空音频数据
this.audioData = [];
} catch (delayError) {
console.error('延迟播放也失败:', delayError);
}
}else{
this.mergeAudioData()
}
}
if (dataInfo.type == 'reply_text') {
// #ifdef APP
uni.showLoading({
title: '思考中',
mask: true
});
// #endif
// #ifdef MP
uni.showLoading({
title: '思考中',
mask: true
});
// #endif
}
if (dataInfo.type == 'interrupt') {
this.audioContext.pause();
// uni.showToast({
// icon: "none",
// duration: 2000,
// title: "被打断"
// })
}
if (dataInfo.type == 'ready') {
// 准备好的
}
}
},
base64ToFile(base64Str, fileName, callback) {
// https://blog.csdn.net/qq_45225600/article/details/149859602
var index = base64Str.indexOf(',');
var base64Str = base64Str.slice(index + 1, base64Str.length);
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, function(fs) {
fs.root.getFile(fileName, { create: true }, function(entry) {
var fullPath = entry.fullPath;
let platform = uni.getSystemInfoSync().platform;
if (platform == 'android') {
// Android实现
var Base64 = plus.android.importClass("android.util.Base64");
var FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
try {
var out = new FileOutputStream(fullPath);
var bytes = Base64.decode(base64Str, Base64.DEFAULT);
out.write(bytes);
out.close();
callback && callback(entry.toLocalURL());
} catch (e) {
console.log(e.message);
}
} else if (platform == 'ios') {
// iOS实现
var NSData = plus.ios.importClass('NSData');
var nsData = new NSData();
nsData = nsData.initWithBase64EncodedStringoptions(base64Str, 0);
if (nsData) {
nsData.plusCallMethod({ writeToFile: fullPath, atomically: true });
plus.ios.deleteObject(nsData);
}
callback && callback(entry.toLocalURL());
}
});
});
},
// 合并音频数据并播放
async mergeAudioData() {
console.log('this.audioData:',this.audioData)
if (this.audioData.length === 0) {
console.log('没有音频数据')
return null
}
try {
// 创建一个完整的 ArrayBuffer 来存储所有音频数据
const totalLength = this.audioData.reduce((acc, buffer) => acc + buffer.byteLength, 0)
console.log('totalLength:',totalLength)
const mergedArrayBuffer = new Uint8Array(totalLength)
console.log('mergedArrayBuffer:',mergedArrayBuffer)
let offset = 0
for (let i = 0; i < this.audioData.length; i++) {
const buffer = new Uint8Array(this.audioData[i])
mergedArrayBuffer.set(buffer, offset)
offset += buffer.byteLength
}
// 将合并后的 ArrayBuffer 保存为文件并播放
const fileName = `recording_${Date.now()}.mp3`
let filePath;
if (this.isApp) {
// App端音频播放处理
try {
// 重新初始化音频上下文,确保状态正确
this.initAudio();
// 先检查存储权限
if (typeof plus !== 'undefined' && plus.android) {
const Context = plus.android.importClass('android.content.Context');
const Environment = plus.android.importClass('android.os.Environment');
const permissions = ['android.permission.READ_EXTERNAL_STORAGE', 'android.permission.WRITE_EXTERNAL_STORAGE'];
plus.android.requestPermissions(permissions, (res) => {
if (res.deniedAlways.length > 0) {
console.error('存储权限被永久拒绝');
}
});
}
// 方案1尝试使用Blob URL直接播放如果支持
if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
console.log('尝试使用Blob URL播放...');
const blob = new Blob([mergedArrayBuffer.buffer], { type: 'audio/mpeg' });
const blobURL = URL.createObjectURL(blob);
console.log('Blob URL:', blobURL);
this.audioContext.src = blobURL;
} else {
// 方案2写入文件后播放
console.log('尝试使用文件方式播放...');
filePath = `${plus.io.PUBLIC_DOWNLOADS}/${fileName}`;
const fileURL = await this.writeFileApp(mergedArrayBuffer.buffer, filePath);
console.log('文件播放路径:', fileURL);
// 尝试多种路径格式
let finalURL = fileURL;
// 如果fileURL是file://格式,尝试去除协议
if (fileURL.startsWith('file://')) {
const pathWithoutProtocol = fileURL.substring(7);
console.log('尝试无协议路径:', pathWithoutProtocol);
}
// 尝试使用plus.io.convertLocalFileSystemURL转换
const convertedURL = plus.io.convertLocalFileSystemURL(filePath);
console.log('转换后的路径:', convertedURL);
this.audioContext.src = finalURL;
}
} catch (error) {
console.error('App端音频播放准备失败:', error);
// 回退到简单的文件写入方式
filePath = `${plus.io.PUBLIC_DOWNLOADS}/${fileName}`;
const fileURL = await this.writeFileApp(mergedArrayBuffer.buffer, filePath);
this.audioContext.src = fileURL;
}
} else {
// 小程序端使用uni API写入文件
filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;
await this.writeFileMiniProgram(mergedArrayBuffer.buffer, filePath);
this.audioContext.src = filePath;
}
console.log('最终音频源:', this.audioContext.src)
// 播放音频
try {
console.log('开始播放...');
// 确保音频上下文已正确创建
if (!this.audioContext) {
console.error('音频上下文未初始化');
this.initAudio();
this.audioContext.src = filePath;
}
// 先设置src再播放
this.audioContext.play();
// 清空音频数据
this.audioData = [];
} catch (playError) {
console.error('音频播放异常:', playError);
console.error('异常详情:', JSON.stringify(playError));
// 尝试延迟播放
setTimeout(() => {
try {
console.log('尝试延迟播放...');
this.audioContext.play();
} catch (delayError) {
console.error('延迟播放也失败:', delayError);
}
}, 500);
this.audioData = [];
}
} catch (error) {
console.error('合并音频数据失败:', error)
return null
}
},
// App端文件写入方法
writeFileApp(buffer, filePath) {
console.log('App端文件写入方法:',buffer, filePath)
return new Promise((resolve, reject) => {
console.log('plus.io.requestFileSystem',plus.io.requestFileSystem)
console.log('plus.io.PUBLIC_DOWNLOADS',plus.io.PUBLIC_DOWNLOADS)
plus.io.requestFileSystem(plus.io.PUBLIC_DOWNLOADS, (fs) => {
console.log('fs.root:',fs.root)
const fileName = filePath.split('/').pop();
fs.root.getFile(fileName, {create: true, exclusive: false}, (fileEntry) => {
console.log('fileEntry',fileEntry)
fileEntry.createWriter((writer) => {
console.log('writer:',writer)
console.log('writer 的方法:', Object.keys(writer))
console.log('buffer type:', typeof buffer, 'buffer instanceof ArrayBuffer:', buffer instanceof ArrayBuffer)
console.log('Blob 是否存在:', typeof Blob !== 'undefined')
console.log('准备进入 try 块...')
try {
console.log('已进入 try 块')
// 检查 Blob 是否可用,如果不可用则尝试直接写入 ArrayBuffer
let writeData;
if (typeof Blob !== 'undefined') {
// 先将 ArrayBuffer 转换为 Blob再写入
console.log('开始创建 Blob...')
writeData = new Blob([buffer], { type: 'audio/mpeg' });
console.log('Blob 创建成功:', writeData, 'size:', writeData.size)
} else {
// Blob 不可用,直接使用 ArrayBuffer
console.log('Blob 不可用,直接使用 ArrayBuffer')
writeData = buffer;
}
// 先设置所有事件监听器
console.log('开始设置事件监听器...')
writer.onerror = (err) => {
console.error('App端文件写入失败:', err)
reject(err)
}
writer.onwriteend = (e) => {
console.log('App端文件写入完成', e)
// 获取文件信息验证
fileEntry.file((file) => {
console.log('文件信息:', {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
})
// 验证文件大小是否大于0
if (file.size === 0) {
console.error('警告: 文件大小为0可能写入失败')
}
}, (err) => {
console.error('获取文件信息失败:', err)
})
// 获取文件路径的多种方式
const toURL = fileEntry.toURL();
const fullPath = fileEntry.fullPath;
console.log('toURL():', toURL)
console.log('fullPath:', fullPath)
let fileURL;
// 优先使用 toURL()如果返回的是有效路径file://开头)
if (toURL && toURL.startsWith('file://')) {
fileURL = toURL;
console.log('使用 toURL() 路径:', fileURL)
} else if (fullPath && fullPath.startsWith('/')) {
// fullPath 是绝对路径(如 /storage/emulated/0/...
// 直接添加 file:// 前缀
fileURL = 'file://' + fullPath;
console.log('使用 fullPath 并添加 file:// 前缀:', fileURL)
} else if (toURL) {
// toURL() 返回相对路径(如 _downloads/xxx.mp3尝试转换
fileURL = plus.io.convertLocalFileSystemURL(toURL);
console.log('使用 toURL() 相对路径转换:', fileURL)
// 如果转换后仍不是 file:// 开头,手动添加
if (!fileURL || !fileURL.startsWith('file://')) {
// 尝试用 fullPath如果可用
if (fullPath && fullPath.startsWith('/')) {
fileURL = 'file://' + fullPath;
console.log('回退到 fullPath 并添加 file:// 前缀:', fileURL)
}
}
}
console.log('App端最终文件URL:', fileURL)
resolve(fileURL)
}
writer.ontruncate = (e) => {
console.log('App端文件清空完成开始写入', e)
console.log('writer.readyState:', writer.readyState)
// truncate 完成后再写入数据
console.log('准备写入数据:', writeData, '类型:', typeof writeData, '是否是ArrayBuffer:', writeData instanceof ArrayBuffer)
// 添加写入开始监听
writer.onwritestart = (e) => {
console.log('文件写入开始', e)
}
// 如果 Blob 不可用ArrayBuffer 需要转换为 Uint8Array 的 buffer
if (writeData instanceof ArrayBuffer) {
console.log('写入 ArrayBuffer大小:', writeData.byteLength, 'bytes')
// FileWriter.write() 应该支持 ArrayBuffer但需要确保格式正确
writer.write(writeData)
} else {
console.log('写入 Blob大小:', writeData.size)
writer.write(writeData)
}
}
console.log('事件监听器设置完成,开始 seek...')
// 清空文件内容(如果文件已存在)
// 先定位到文件开头
writer.seek(0);
console.log('seek 完成,开始 truncate...')
// 然后清空文件,这会触发 ontruncate 事件
writer.truncate(0);
console.log('truncate 调用完成')
} catch (error) {
console.error('写入过程中发生错误:', error)
reject(error)
}
}, (err) => {
console.error('App端创建写入器失败:', err)
reject(err)
})
}, (err) => {
console.error('App端获取文件失败:', err)
reject(err)
})
}, (err) => {
console.error('App端请求文件系统失败:', err)
reject(err)
})
})
},
// 小程序端文件写入方法
writeFileMiniProgram(buffer, filePath) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager()
fs.writeFile({
filePath: filePath,
data: buffer,
encoding: 'binary',
success: (res) => {
console.log('小程序端文件写入成功:', res)
resolve(filePath)
},
fail: (err) => {
console.error('小程序端文件写入失败:', err)
reject(err)
}
})
})
},
back() {
uni.navigateBack({
delta: 1,
});
},
generateClick() {
uni.navigateBack({
delta: 2,
});
},
}
}
</script>
<style>
page {
/* opacity: 0.7; */
}
</style>
<style>
.body {
position: relative;
}
.back {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.body {
position: relative;
padding: 0 60rpx;
}
.header {
position: absolute;
right: 28rpx;
top: 11%;
z-index: 5;
}
.header_content {
position: relative;
padding: 12rpx 24rpx;
background: linear-gradient(135deg, rgba(159, 71, 255, 0.6) 0%, rgba(0, 83, 250, 0.6) 100%);
border-radius: 12rpx;
text-align: center;
}
.header_time {
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
}
.header_module {
position: relative;
}
.header_title {
margin: 0 8rpx 0 0;
padding: 0 12rpx;
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
background: linear-gradient(135deg, rgba(159, 71, 255, 0.6) 0%, rgba(0, 83, 250, 0.6) 100%);
border-radius: 12rpx;
}
.header_title:nth-child(3) {
margin: 0 0 0 0;
}
.header_recharge {
position: relative;
font-weight: 400;
font-size: 24rpx;
color: #FFFFFF;
line-height: 50rpx;
}
.header_recharge image {
margin: 0 0 0 10rpx;
width: 6rpx;
height: 10rpx;
}
/* 删除原来的voice样式添加新的动态波形样式 */
.dynamic-wave {
position: absolute;
left: 0;
right: 0;
bottom: 50%;
width: 300rpx;
/* 增加整体宽度 */
margin: 0 auto;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
height: 150rpx;
/* 增加容器高度 */
}
.wave-bar {
width: 8rpx;
/* 增加单个波形条的宽度 */
height: 40rpx;
/* 增加基础高度 */
margin: 0 4rpx;
/* 增加间距 */
background: linear-gradient(to top, #9f47ff, #0053fa);
border-radius: 4rpx;
/* 调整圆角 */
animation: wave 1.5s infinite ease-in-out;
}
@keyframes wave {
0%,
100% {
height: 40rpx;
/* 调整基础高度 */
opacity: 0.6;
}
50% {
height: 120rpx;
/* 调整最大高度 */
opacity: 1;
}
}
.opt {
position: absolute;
left: 0;
right: 0;
bottom: 15%;
margin: 0 140rpx;
z-index: 5;
}
.opt_item {
position: relative;
}
.opt_image {
width: 132rpx;
height: 132rpx;
border-radius: 100rpx;
display: block;
}
.opt_name {
margin: 26rpx 0 0 0;
font-weight: 500;
font-size: 32rpx;
color: #FFFFFF;
line-height: 50rpx;
text-align: center;
}
.black {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 812rpx;
background: linear-gradient(178deg, rgba(0, 0, 0, 0) 0%, #000000 100%);
z-index: 2;
}
</style>