1436 lines
43 KiB
Vue
1436 lines
43 KiB
Vue
<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="mic-permission-switch" @click="toggleMicPermission">
|
||
<text class="mic-emoji">{{ micEnabled ? '🎤' : '🔇' }}</text>
|
||
</view>
|
||
<view class="header">
|
||
<view class="header_content">
|
||
<view class="header_time">{{ isVip ? 'VIP 无限通话' : '通话剩余时间' }}</view>
|
||
<view class="header_module fa sb" v-if="!isVip">
|
||
<view class="header_title faj">{{ formatTime(remainingTime).hours }}</view>
|
||
<view class="header_title faj">{{ formatTime(remainingTime).minutes }}</view>
|
||
<view class="header_title faj">{{ formatTime(remainingTime).seconds }}</view>
|
||
</view>
|
||
<view class="header_recharge faj" @click="goRecharge">充值<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">
|
||
<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 mic-button"
|
||
@touchstart.stop.prevent="startTalking"
|
||
@touchend.stop.prevent="stopTalking"
|
||
@touchcancel.stop.prevent="stopTalking"
|
||
@click="testClick"
|
||
:class="{ 'talking': isTalking }">
|
||
<image class="opt_image" src="/static/images/phone_a1.png" mode="widthFix"></image>
|
||
<view class="opt_name">{{ isTalking ? '松开结束' : '按住说话' }}</view>
|
||
</view>
|
||
</view>
|
||
<view class="black"></view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import {
|
||
|
||
} from '@/utils/api.js'
|
||
import notHave from '@/components/not-have.vue';
|
||
import { baseURLPy } from '@/utils/request.js'
|
||
import topSafety from '@/components/top-safety.vue';
|
||
|
||
// 在组件外部初始化 recorderManager(所有平台统一使用)
|
||
let recorderManager = null;
|
||
|
||
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: [],
|
||
totalDuration: 300000, // 默认 5 分钟
|
||
remainingTime: 300000,
|
||
timer: null,
|
||
isVip: false,
|
||
isTalking: false, // 是否正在说话
|
||
micEnabled: true, // 麦克风是否开启
|
||
isReconnecting: false // 是否正在重连
|
||
}
|
||
},
|
||
onLoad() {
|
||
// 检测平台
|
||
const systemInfo = uni.getSystemInfoSync()
|
||
console.log('systemInfo', systemInfo)
|
||
|
||
// 所有平台统一使用 uni.getRecorderManager()
|
||
recorderManager = uni.getRecorderManager();
|
||
console.log('✅ recorderManager 初始化完成')
|
||
|
||
// 设置录音监听器(只设置一次)
|
||
this.setupRecorderListeners()
|
||
|
||
this.getCallDuration()
|
||
this.initAudio()
|
||
},
|
||
onUnload() {
|
||
this.stopCall()
|
||
if (this.timer) {
|
||
clearInterval(this.timer)
|
||
}
|
||
},
|
||
methods: {
|
||
getCallDuration() {
|
||
uni.request({
|
||
url: baseURLPy + '/voice/call/duration',
|
||
method: 'GET',
|
||
header: {
|
||
'Authorization': 'Bearer ' + uni.getStorageSync("token")
|
||
},
|
||
success: (res) => {
|
||
console.log('通话时长配置:', res.data)
|
||
if (res.data.code === 1 && res.data.data) {
|
||
const duration = res.data.data.duration
|
||
this.isVip = res.data.data.is_vip
|
||
if (duration > 0) {
|
||
this.totalDuration = duration
|
||
this.remainingTime = duration
|
||
} else {
|
||
// VIP 用户无限制,设置为 24 小时
|
||
this.totalDuration = 24 * 60 * 60 * 1000
|
||
this.remainingTime = 24 * 60 * 60 * 1000
|
||
}
|
||
}
|
||
this.connectWebSocket()
|
||
},
|
||
fail: (err) => {
|
||
console.error('获取通话时长失败:', err)
|
||
// 失败时使用默认值
|
||
this.connectWebSocket()
|
||
}
|
||
})
|
||
},
|
||
formatTime(ms) {
|
||
const totalSeconds = Math.floor(ms / 1000)
|
||
const hours = Math.floor(totalSeconds / 3600)
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||
const seconds = totalSeconds % 60
|
||
return {
|
||
hours: String(hours).padStart(2, '0'),
|
||
minutes: String(minutes).padStart(2, '0'),
|
||
seconds: String(seconds).padStart(2, '0')
|
||
}
|
||
},
|
||
startTimer() {
|
||
if (this.timer) {
|
||
clearInterval(this.timer)
|
||
}
|
||
this.timer = setInterval(() => {
|
||
this.remainingTime -= 1000
|
||
if (this.remainingTime <= 0) {
|
||
clearInterval(this.timer)
|
||
uni.showToast({
|
||
title: '通话时间已用完',
|
||
icon: 'none'
|
||
})
|
||
setTimeout(() => {
|
||
uni.navigateBack()
|
||
}, 1500)
|
||
} else if (this.remainingTime === 60000) {
|
||
// 剩余 1 分钟提示
|
||
uni.showToast({
|
||
title: '剩余 1 分钟',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
}, 1000)
|
||
},
|
||
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() {
|
||
// 如果已经有连接且状态正常,不重复连接
|
||
if (this.socketTask && this.socketTask.readyState === 1) {
|
||
console.log('⚠️ WebSocket 已连接,跳过重复连接')
|
||
return
|
||
}
|
||
|
||
// 如果正在连接中,不重复连接
|
||
if (this.socketTask && this.socketTask.readyState === 0) {
|
||
console.log('⚠️ WebSocket 正在连接中,请稍候...')
|
||
return
|
||
}
|
||
|
||
// 关闭旧连接
|
||
if (this.socketTask) {
|
||
console.log('🔌 关闭旧的 WebSocket 连接...')
|
||
try {
|
||
this.socketTask.close()
|
||
} catch (e) {
|
||
console.error('关闭旧连接失败:', e)
|
||
}
|
||
this.socketTask = null
|
||
}
|
||
|
||
// 根据 baseURLPy 构建 WebSocket URL
|
||
let wsUrl = baseURLPy.replace('http://', 'ws://').replace('https://', 'wss://') + '/voice/call'
|
||
console.log('🔗 WebSocket URL:', wsUrl)
|
||
|
||
this.socketTask = uni.connectSocket({
|
||
url: wsUrl,
|
||
header: {
|
||
"content-type": "application/json",
|
||
'Authorization': 'Bearer ' + uni.getStorageSync("token") || ""
|
||
},
|
||
success: () => console.log('WS 连接成功')
|
||
});
|
||
this.socketTask.onOpen((res) => {
|
||
console.log('WebSocket onOpen:', res)
|
||
// 不要在这里自动开始录音,等用户按住按钮时再开始
|
||
// this.startRecording();
|
||
this.startTimer();
|
||
});
|
||
this.socketTask.onMessage((res) => {
|
||
console.log('📨 收到服务器消息, 数据类型:', typeof res.data)
|
||
console.log('📨 消息内容:', res.data)
|
||
this.handleServerMessage(res.data);
|
||
});
|
||
this.socketTask.onError((err) => {
|
||
console.error('❌ WS 错误', err);
|
||
console.error('错误详情:', JSON.stringify(err));
|
||
|
||
// 尝试重连
|
||
if (err.errMsg && err.errMsg.includes('timeout')) {
|
||
console.log('⚠️ WebSocket 超时,尝试重连...')
|
||
setTimeout(() => {
|
||
this.connectWebSocket()
|
||
}, 2000)
|
||
}
|
||
});
|
||
this.socketTask.onClose((res) => {
|
||
console.log('❌ WebSocket 关闭, code:', res.code, 'reason:', res.reason)
|
||
|
||
if (res.code !== 1000) {
|
||
console.error('⚠️ 非正常关闭')
|
||
}
|
||
|
||
if (this.isRecording && recorderManager) {
|
||
console.log('关闭录音')
|
||
recorderManager.stop()
|
||
}
|
||
|
||
// 自动重连(避免重复重连)
|
||
if (!this.isReconnecting) {
|
||
this.isReconnecting = true
|
||
console.log('⚠️ WebSocket 已关闭,3秒后自动重连...')
|
||
setTimeout(() => {
|
||
console.log('🔄 尝试重新连接 WebSocket...')
|
||
this.isReconnecting = false
|
||
this.connectWebSocket()
|
||
}, 3000)
|
||
}
|
||
})
|
||
},
|
||
// 设置录音监听器(只在初始化时调用一次)
|
||
setupRecorderListeners() {
|
||
if (!recorderManager) {
|
||
console.error('❌ recorderManager 未初始化')
|
||
return
|
||
}
|
||
|
||
console.log('📝 设置录音监听器...')
|
||
|
||
// 监听录音开始
|
||
recorderManager.onStart(() => {
|
||
console.log('✅ 录音已开始')
|
||
console.log('当前时间:', new Date().toLocaleTimeString())
|
||
})
|
||
|
||
// 监听录音错误
|
||
recorderManager.onError((err) => {
|
||
console.error('❌ 录音错误:', err)
|
||
console.error('错误详情:', JSON.stringify(err))
|
||
uni.showToast({
|
||
title: '录音失败: ' + (err.errMsg || '未知错误'),
|
||
icon: 'none'
|
||
})
|
||
this.isRecording = false
|
||
})
|
||
|
||
// 监听录音停止 - 作为备用方案
|
||
recorderManager.onStop((res) => {
|
||
console.log('⏹️ 录音已停止')
|
||
console.log('📋 完整的 res 对象:', JSON.stringify(res))
|
||
console.log('📁 文件路径:', res.tempFilePath)
|
||
console.log('⏱️ 录音时长:', res.duration, 'ms')
|
||
console.log('📦 文件大小:', res.fileSize, 'bytes')
|
||
|
||
// 检查录音是否有效
|
||
if (!res.tempFilePath) {
|
||
console.error('❌ 没有录音文件路径!')
|
||
uni.showToast({
|
||
title: '录音失败:没有生成文件',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 检查录音时长
|
||
if (res.duration !== undefined && res.duration < 500) {
|
||
console.error('❌ 录音时长太短:', res.duration, 'ms')
|
||
uni.showToast({
|
||
title: '录音太短,请至少说 2 秒',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
console.log('✅ 录音文件路径有效,准备读取文件...')
|
||
|
||
// 检查 WebSocket 状态
|
||
console.log('🔍 检查 WebSocket 状态...')
|
||
console.log('🔍 this.socketTask 是否存在:', !!this.socketTask)
|
||
|
||
if (!this.socketTask) {
|
||
console.error('❌ socketTask 不存在')
|
||
uni.showToast({
|
||
title: 'WebSocket 未连接',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
console.log('🔌 WebSocket 状态:', this.socketTask.readyState)
|
||
console.log('🔌 状态说明: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED')
|
||
|
||
if (this.socketTask.readyState !== 1) {
|
||
console.error('❌ WebSocket 未连接,无法发送,状态:', this.socketTask.readyState)
|
||
uni.showToast({
|
||
title: 'WebSocket 未连接,请重新进入',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
console.log('✅ WebSocket 状态正常,开始读取文件...')
|
||
|
||
// 处理文件路径(确保是绝对路径)
|
||
let filePath = res.tempFilePath
|
||
// 如果是相对路径,转换为绝对路径
|
||
if (!filePath.startsWith('/') && !filePath.includes('://')) {
|
||
// #ifdef APP-PLUS
|
||
filePath = plus.io.convertLocalFileSystemURL(filePath)
|
||
console.log('📁 转换后的绝对路径:', filePath)
|
||
// #endif
|
||
}
|
||
|
||
// 读取文件内容
|
||
const fs = uni.getFileSystemManager()
|
||
console.log('📂 获取文件系统管理器:', fs ? '成功' : '失败')
|
||
console.log('📁 准备读取文件:', filePath)
|
||
|
||
// 添加超时保护
|
||
let readTimeout = setTimeout(() => {
|
||
console.error('❌ 文件读取超时(5秒)')
|
||
uni.showToast({
|
||
title: '文件读取超时',
|
||
icon: 'none'
|
||
})
|
||
}, 5000)
|
||
|
||
fs.readFile({
|
||
filePath: filePath,
|
||
// 不指定 encoding,让它返回 ArrayBuffer
|
||
success: (fileRes) => {
|
||
clearTimeout(readTimeout)
|
||
console.log('✅ 文件读取成功')
|
||
console.log('📊 数据类型:', typeof fileRes.data)
|
||
console.log('📊 是否为 ArrayBuffer:', fileRes.data instanceof ArrayBuffer)
|
||
|
||
const actualSize = fileRes.data.byteLength || fileRes.data.length
|
||
console.log('📊 实际文件大小:', actualSize, 'bytes')
|
||
console.log('📊 预计录音时长:', (actualSize / 32000).toFixed(2), '秒')
|
||
|
||
// 验证文件大小
|
||
if (actualSize < 32000) {
|
||
console.error('❌ 文件太小(< 1秒),可能录音失败')
|
||
uni.showToast({
|
||
title: '录音文件太小,请重试',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 再次检查 WebSocket 状态
|
||
if (this.socketTask.readyState !== 1) {
|
||
console.error('❌ 读取文件后 WebSocket 已断开')
|
||
return
|
||
}
|
||
|
||
// 确保数据是 ArrayBuffer
|
||
let audioData = fileRes.data
|
||
if (!(audioData instanceof ArrayBuffer)) {
|
||
console.error('❌ 数据不是 ArrayBuffer,类型:', typeof audioData)
|
||
uni.showToast({
|
||
title: '音频数据格式错误',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 分片发送音频数据
|
||
this.sendAudioInChunks(audioData)
|
||
},
|
||
fail: (err) => {
|
||
clearTimeout(readTimeout)
|
||
console.error('❌ 文件读取失败:', err)
|
||
console.error('错误代码:', err.errCode)
|
||
console.error('错误信息:', err.errMsg)
|
||
console.error('完整错误:', JSON.stringify(err))
|
||
console.error('尝试读取的文件路径:', filePath)
|
||
uni.showToast({
|
||
title: '文件读取失败: ' + (err.errMsg || '未知错误'),
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
})
|
||
|
||
// 监听音频帧 - 实时发送
|
||
let frameCount = 0
|
||
recorderManager.onFrameRecorded((res) => {
|
||
frameCount++
|
||
const { frameBuffer, isLastFrame } = res
|
||
|
||
console.log(`🎤 收到音频帧 #${frameCount}, isTalking:`, this.isTalking, 'frameBuffer size:', frameBuffer ? frameBuffer.byteLength : 'null')
|
||
|
||
if (!frameBuffer) {
|
||
console.error('❌ frameBuffer 为空!')
|
||
return
|
||
}
|
||
|
||
// 只有在说话状态下才发送音频数据
|
||
if (this.isTalking && this.socketTask && this.socketTask.readyState === 1) {
|
||
console.log('✅ 发送音频帧到服务器, 帧号:', frameCount)
|
||
this.socketTask.send({
|
||
data: frameBuffer,
|
||
success: () => {
|
||
console.log('✅ 音频帧发送成功, 帧号:', frameCount)
|
||
},
|
||
fail: (err) => {
|
||
console.error('❌ 音频帧发送失败:', err)
|
||
}
|
||
})
|
||
} else {
|
||
console.log('⏸️ 不发送音频帧 - isTalking:', this.isTalking)
|
||
}
|
||
})
|
||
|
||
console.log('✅ 所有录音监听器已设置')
|
||
},
|
||
// 切换麦克风权限开关
|
||
toggleMicPermission() {
|
||
this.micEnabled = !this.micEnabled
|
||
if (this.micEnabled) {
|
||
uni.showToast({
|
||
title: '麦克风已开启',
|
||
icon: 'none'
|
||
})
|
||
} else {
|
||
uni.showToast({
|
||
title: '麦克风已关闭',
|
||
icon: 'none'
|
||
})
|
||
// 如果正在说话,停止录音
|
||
if (this.isTalking) {
|
||
this.stopTalking()
|
||
}
|
||
}
|
||
},
|
||
// 测试点击
|
||
testClick() {
|
||
console.log('🔥🔥🔥 ===== testClick 被调用 ===== 🔥🔥🔥')
|
||
console.log('🔥🔥🔥 按钮被点击了!')
|
||
console.log('🔥 当前时间:', new Date().toLocaleTimeString())
|
||
uni.showToast({
|
||
title: '按钮可以点击',
|
||
icon: 'none'
|
||
})
|
||
},
|
||
// 开始说话(按住)
|
||
// 开始说话(按住)
|
||
async startTalking(e) {
|
||
console.log('🔥🔥🔥 ===== startTalking 被调用 ===== 🔥🔥🔥')
|
||
console.log('🔥 事件对象:', e)
|
||
console.log('🔥 当前时间:', new Date().toLocaleTimeString())
|
||
console.log('=== startTalking 被调用 ===')
|
||
console.log('事件对象:', e)
|
||
console.log('micEnabled:', this.micEnabled, 'isRecording:', this.isRecording)
|
||
|
||
if (!this.micEnabled) {
|
||
uni.showToast({
|
||
title: '请先开启麦克风权限',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 检查 WebSocket 连接状态
|
||
if (!this.socketTask) {
|
||
console.error('❌ socketTask 不存在,尝试建立连接...')
|
||
uni.showToast({
|
||
title: '正在连接,请稍候...',
|
||
icon: 'loading',
|
||
duration: 2000
|
||
})
|
||
this.connectWebSocket()
|
||
|
||
// 等待连接建立(最多 3 秒)
|
||
for (let i = 0; i < 30; i++) {
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
if (this.socketTask && this.socketTask.readyState === 1) {
|
||
console.log('✅ WebSocket 连接成功')
|
||
uni.hideToast()
|
||
break
|
||
}
|
||
}
|
||
|
||
// 如果还是没有连接,放弃
|
||
if (!this.socketTask || this.socketTask.readyState !== 1) {
|
||
console.error('❌ WebSocket 连接超时')
|
||
uni.showToast({
|
||
title: 'WebSocket 连接失败,请重试',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
if (this.socketTask.readyState !== 1) {
|
||
console.error('❌ WebSocket 未连接, 状态:', this.socketTask.readyState)
|
||
uni.showToast({
|
||
title: 'WebSocket 未连接,正在重连...',
|
||
icon: 'none'
|
||
})
|
||
this.connectWebSocket()
|
||
return
|
||
}
|
||
|
||
this.isTalking = true
|
||
console.log('✅ 开始说话, isTalking 设置为:', this.isTalking)
|
||
|
||
// 发送 ptt_on 信号
|
||
console.log('📤 发送 ptt_on 信号')
|
||
this.socketTask.send({
|
||
data: 'ptt_on',
|
||
success: () => {
|
||
console.log('✅ ptt_on 信号发送成功')
|
||
},
|
||
fail: (err) => {
|
||
console.error('❌ ptt_on 信号发送失败:', err)
|
||
}
|
||
})
|
||
|
||
// 如果录音还没开始,先启动录音
|
||
if (!this.isRecording) {
|
||
console.log('录音未启动,开始启动录音')
|
||
this.startRecording()
|
||
} else {
|
||
console.log('录音已在运行')
|
||
}
|
||
},
|
||
// 停止说话(松开)- 停止录音并发送
|
||
stopTalking(e) {
|
||
console.log('=== stopTalking 被调用 ===')
|
||
console.log('事件对象:', e)
|
||
console.log('当前 isTalking:', this.isTalking)
|
||
|
||
this.isTalking = false
|
||
console.log('❌ 停止说话, isTalking 设置为:', this.isTalking)
|
||
|
||
// 停止录音,触发 onStop 回调发送文件
|
||
if (this.isRecording && recorderManager) {
|
||
console.log('🛑 停止录音并准备发送...')
|
||
recorderManager.stop()
|
||
this.isRecording = false
|
||
}
|
||
},
|
||
// 开始录制
|
||
async startRecording() {
|
||
console.log('=== startRecording 被调用 ===')
|
||
console.log('isRecording:', this.isRecording)
|
||
console.log('socketTask 状态:', this.socketTask ? this.socketTask.readyState : 'null')
|
||
|
||
if (this.isRecording) {
|
||
console.log('录音已在进行中,跳过')
|
||
return
|
||
}
|
||
|
||
this.isRecording = true
|
||
this.status = 'Call Started'
|
||
|
||
// 检查 recorderManager
|
||
if (!recorderManager) {
|
||
console.error('❌ recorderManager 未初始化')
|
||
uni.showToast({
|
||
title: '录音功能初始化失败',
|
||
icon: 'none'
|
||
})
|
||
this.isRecording = false
|
||
return
|
||
}
|
||
|
||
console.log('🎙️ 启动 recorderManager')
|
||
try {
|
||
// 使用 PCM 格式匹配服务器期望
|
||
const recorderOptions = {
|
||
duration: 600000, // 10 分钟
|
||
sampleRate: 16000, // 必须 16kHz,匹配服务器
|
||
numberOfChannels: 1, // 单声道
|
||
encodeBitRate: 48000,
|
||
format: 'pcm', // 使用 PCM 格式,匹配服务器
|
||
frameSize: 5, // 启用 onFrameRecorded,每 5 帧回调一次
|
||
audioSource: 'auto'
|
||
}
|
||
|
||
console.log('📋 录音参数:', JSON.stringify(recorderOptions))
|
||
console.log('⚠️ 注意:启用了实时音频帧传输(frameSize: 5)')
|
||
|
||
recorderManager.start(recorderOptions)
|
||
console.log('✅ recorderManager.start 已调用')
|
||
} catch (err) {
|
||
console.error('❌ 启动录音失败:', err)
|
||
this.isRecording = false
|
||
uni.showToast({
|
||
title: '启动录音失败: ' + err.message,
|
||
icon: 'none'
|
||
})
|
||
}
|
||
},
|
||
// 分片发送音频数据(按照官方推荐参数)
|
||
async sendAudioInChunks(audioData) {
|
||
// 官方推荐:每包3200字节(约100ms音频),延迟100ms
|
||
// PCM 16kHz 单声道:16000 * 2 * 0.1 = 3200 bytes/100ms
|
||
const chunkSize = 3200 // 3.2KB per chunk(官方推荐)
|
||
const chunkDelay = 100 // 100ms(官方推荐)
|
||
|
||
// 确保 audioData 是 ArrayBuffer
|
||
if (!(audioData instanceof ArrayBuffer)) {
|
||
console.error('❌ audioData 不是 ArrayBuffer,类型:', typeof audioData)
|
||
uni.showToast({
|
||
title: '音频数据格式错误',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
const totalSize = audioData.byteLength
|
||
let offset = 0
|
||
let chunkCount = 0
|
||
|
||
console.log('📦 开始分片发送(官方推荐参数)')
|
||
console.log('📊 总大小:', totalSize, 'bytes')
|
||
console.log('📊 每片大小:', chunkSize, 'bytes')
|
||
console.log('📊 发送间隔:', chunkDelay, 'ms')
|
||
console.log('📊 预计发送时间:', Math.ceil(totalSize / chunkSize) * chunkDelay, 'ms')
|
||
console.log('📊 预计录音时长:', (totalSize / 32000).toFixed(2), '秒')
|
||
|
||
// 显示加载提示
|
||
uni.showLoading({
|
||
title: '发送中...',
|
||
mask: true
|
||
})
|
||
|
||
// 使用 Promise 确保顺序发送
|
||
const sendChunk = (chunk, index) => {
|
||
return new Promise((resolve, reject) => {
|
||
console.log(`📤 发送第 ${index} 片,大小: ${chunk.byteLength} bytes`)
|
||
|
||
this.socketTask.send({
|
||
data: chunk,
|
||
success: () => {
|
||
console.log(`✅ 第 ${index} 片发送成功`)
|
||
resolve()
|
||
},
|
||
fail: (err) => {
|
||
console.error(`❌ 第 ${index} 片发送失败:`, err)
|
||
reject(err)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
try {
|
||
// 顺序发送所有片段
|
||
while (offset < totalSize) {
|
||
const end = Math.min(offset + chunkSize, totalSize)
|
||
const chunk = audioData.slice(offset, end)
|
||
chunkCount++
|
||
|
||
await sendChunk(chunk, chunkCount)
|
||
offset = end
|
||
|
||
// 延迟指定时间再发送下一片(模拟实时流)
|
||
if (offset < totalSize) {
|
||
await new Promise(resolve => setTimeout(resolve, chunkDelay))
|
||
}
|
||
}
|
||
|
||
console.log('✅ 所有音频片段发送完成,共', chunkCount, '片')
|
||
console.log('📊 实际发送时间:', chunkCount * chunkDelay, 'ms')
|
||
|
||
// 延迟 100ms 后发送结束标记
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
|
||
// 发送结束标记
|
||
await new Promise((resolve, reject) => {
|
||
console.log('📤 发送结束标记 "end"')
|
||
this.socketTask.send({
|
||
data: 'end',
|
||
success: () => {
|
||
console.log('✅ 结束标记发送成功,等待服务器处理...')
|
||
uni.showLoading({
|
||
title: '识别中...',
|
||
mask: true
|
||
})
|
||
resolve()
|
||
},
|
||
fail: (err) => {
|
||
console.error('❌ 结束标记发送失败:', err)
|
||
reject(err)
|
||
}
|
||
})
|
||
})
|
||
|
||
} catch (err) {
|
||
console.error('❌ 发送过程出错:', err)
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: '发送失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
},
|
||
stopCall() {
|
||
this.isRecording = false;
|
||
if (recorderManager) {
|
||
recorderManager.stop();
|
||
}
|
||
if (this.socketTask) {
|
||
this.socketTask.close();
|
||
}
|
||
if (this.audioContext) {
|
||
this.audioContext.pause();
|
||
this.audioContext.destroy();
|
||
}
|
||
if (this.timer) {
|
||
clearInterval(this.timer);
|
||
}
|
||
},
|
||
// 接收消息
|
||
async handleServerMessage(data) {
|
||
console.log('=== handleServerMessage 被调用 ===')
|
||
console.log('📥 接收到的信息, 类型:', typeof(data))
|
||
|
||
// 隐藏加载提示
|
||
uni.hideLoading()
|
||
|
||
if (data && typeof(data) == 'object') {
|
||
//处理数据流(音频数据)
|
||
this.audioData.push(data);
|
||
console.log('🎵 收到音频数据流, 当前缓存数量:', this.audioData.length, '本次大小:', data.byteLength)
|
||
} else {
|
||
//处理非数据流(控制消息)
|
||
try {
|
||
let dataInfo = JSON.parse(data)
|
||
console.log('📋 收到控制消息, type:', dataInfo.type)
|
||
console.log('📋 完整消息:', JSON.stringify(dataInfo))
|
||
|
||
if (dataInfo.type == 'reply_end') {
|
||
// 检测是否为 App 平台
|
||
// #ifdef APP-PLUS
|
||
uni.showLoading({
|
||
title: '思考中',
|
||
mask: true
|
||
});
|
||
console.log('reply_end (App平台)')
|
||
// 创建一个完整的 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;
|
||
}
|
||
|
||
const base64Audio = uni.arrayBufferToBase64(mergedArrayBuffer.buffer);
|
||
const base64WithPrefix = `data:audio/mp3;base64,${base64Audio}`;
|
||
|
||
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)
|
||
this.audioContext.src = filePath;
|
||
try {
|
||
uni.hideLoading()
|
||
console.log('尝试播放...');
|
||
this.audioContext.play();
|
||
// 清空音频数据
|
||
this.audioData = [];
|
||
} catch (delayError) {
|
||
console.error('播放失败:', delayError);
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
this.mergeAudioData()
|
||
// #endif
|
||
}
|
||
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') {
|
||
// 准备好的
|
||
}
|
||
} catch (parseError) {
|
||
console.error('❌ JSON 解析失败:', parseError)
|
||
console.error('原始数据:', data)
|
||
}
|
||
}
|
||
},
|
||
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;
|
||
|
||
// #ifdef APP-PLUS
|
||
// 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;
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
// 小程序端使用uni API写入文件
|
||
filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;
|
||
await this.writeFileMiniProgram(mergedArrayBuffer.buffer, filePath);
|
||
this.audioContext.src = filePath;
|
||
// #endif
|
||
|
||
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,
|
||
});
|
||
},
|
||
goRecharge() {
|
||
uni.showToast({
|
||
title: '充值功能开发中',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
</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;
|
||
}
|
||
}
|
||
|
||
/* 麦克风权限开关样式 */
|
||
.mic-permission-switch {
|
||
position: absolute;
|
||
right: 28rpx;
|
||
top: 100rpx;
|
||
z-index: 10;
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(10rpx);
|
||
}
|
||
|
||
.mic-emoji {
|
||
font-size: 48rpx;
|
||
}
|
||
|
||
.opt {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 15%;
|
||
margin: 0 auto;
|
||
z-index: 5;
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 100rpx;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 按住说话按钮样式 */
|
||
.mic-button {
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.mic-button.talking {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.mic-button.talking .opt_image {
|
||
box-shadow: 0 0 20rpx rgba(159, 71, 255, 0.8);
|
||
}
|
||
|
||
.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> |