1014 lines
26 KiB
Vue
1014 lines
26 KiB
Vue
<template>
|
||
<view class="video-call-container">
|
||
<!-- 顶部信息 -->
|
||
<view class="header">
|
||
<text class="caller-name">📹 与{{ callerName }}视频通话</text>
|
||
<text class="call-duration">{{ callDuration }}</text>
|
||
</view>
|
||
|
||
<!-- 视频播放区域 -->
|
||
<view class="video-section">
|
||
<video
|
||
v-if="currentVideoUrl"
|
||
:src="currentVideoUrl"
|
||
:loop="false"
|
||
:autoplay="true"
|
||
id="videoPlayer"
|
||
:show-center-play-btn="false"
|
||
:show-play-btn="false"
|
||
:controls="false"
|
||
:enable-progress-gesture="false"
|
||
:show-progress="false"
|
||
:show-fullscreen-btn="false"
|
||
:show-loading="true"
|
||
:object-fit="'contain'"
|
||
:poster="''"
|
||
class="video-player"
|
||
@play="onVideoPlay"
|
||
@pause="onVideoPause"
|
||
@ended="onVideoEnded"
|
||
@error="onVideoError"
|
||
@loadedmetadata="onVideoLoaded"
|
||
@waiting="onVideoWaiting"
|
||
@canplay="onVideoCanPlay"
|
||
@timeupdate="onVideoTimeUpdate"
|
||
></video>
|
||
|
||
<!-- 视频加载提示 -->
|
||
<view v-if="isVideoLoading" class="video-loading">
|
||
<text>📹 视频加载中...</text>
|
||
</view>
|
||
|
||
<!-- 说话状态指示器 -->
|
||
<view v-if="isSpeaking" class="speaking-indicator">
|
||
<text class="pulse">🔊 正在说话...</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 对话历史 -->
|
||
<scroll-view scroll-y class="chat-history" :scroll-top="scrollTop" scroll-with-animation>
|
||
<view v-for="(msg, index) in messages" :key="index" :class="['message', msg.role === 'user' ? 'user-message' : 'ai-message']">
|
||
<view class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</view>
|
||
<view class="message-content">
|
||
<text class="message-text">{{ msg.content }}</text>
|
||
<text class="message-time">{{ msg.time }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="messages.length === 0" class="empty-hint">
|
||
<text>👋 开始对话吧</text>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 控制按钮 -->
|
||
<view class="controls">
|
||
<view v-if="voiceId && voiceId.startsWith('cosyvoice-v3-plus-')" class="form-section">
|
||
<view class="form-label">方言</view>
|
||
<picker mode="selector" :range="dialectOptions" @change="onDialectChange">
|
||
<view class="picker-large">
|
||
{{ selectedDialect || '请选择方言(可选)' }}
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view v-if="voiceId && voiceId.startsWith('cosyvoice-v3-plus-')" class="form-section">
|
||
<view class="form-label">语言提示(可选)</view>
|
||
<picker mode="selector" :range="languageHintOptions" @change="onLanguageHintChange">
|
||
<view class="picker-large">
|
||
{{ selectedLanguageHintLabel || '请选择语言(可选)' }}
|
||
</view>
|
||
</picker>
|
||
<view class="hint-text">💡 仅处理第一个值;不设置不生效</view>
|
||
</view>
|
||
|
||
<view class="control-row">
|
||
<button v-if="!isRecording && !isProcessing" class="talk-btn" @click="startTalking">
|
||
🎤 开始说话
|
||
</button>
|
||
<button v-if="isRecording" class="talk-btn recording" @click="stopTalking">
|
||
⏹️ 停止录音
|
||
</button>
|
||
<button v-if="isProcessing" class="talk-btn processing" disabled>
|
||
⏳ {{ processingText }}
|
||
</button>
|
||
</view>
|
||
<view class="control-row">
|
||
<button class="hangup-btn" @click="hangUp">
|
||
📞 挂断
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { API_BASE, API_ENDPOINTS, buildURL } from '@/config/api.js';
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
API_BASE,
|
||
|
||
// 视频资源
|
||
idleVideoUrl: '', // 待机视频URL
|
||
talkingVideoUrl: '', // 说话视频URL
|
||
currentVideoUrl: '', // 当前播放的视频URL
|
||
isVideoLoading: true, // 视频是否正在加载
|
||
videoContext: null, // 视频控制器
|
||
hasPlayedInitial: false, // 是否已播放初始1秒
|
||
|
||
// 通话信息
|
||
callerName: '对方',
|
||
voiceId: '',
|
||
selectedDialect: '',
|
||
selectedLanguageHint: '',
|
||
selectedLanguageHintLabel: '',
|
||
languageHintOptions: ['中文(zh)', '英文(en)', '法语(fr)', '德语(de)', '日语(ja)', '韩语(ko)', '俄语(ru)'],
|
||
dialectOptions: ['广东话', '东北话', '甘肃话', '贵州话', '河南话', '湖北话', '江西话', '闽南话', '宁夏话', '山西话', '陕西话', '山东话', '上海话', '四川话', '天津话', '云南话'],
|
||
callDuration: '00:00',
|
||
callStartTime: null,
|
||
durationTimer: null,
|
||
|
||
// 状态
|
||
isIdle: true, // 是否待机状态
|
||
isSpeaking: false, // AI是否在说话
|
||
isRecording: false,
|
||
isProcessing: false,
|
||
processingText: '处理中...',
|
||
|
||
// 对话
|
||
messages: [],
|
||
systemPrompt: '你是一位温暖的对话者,用亲切的语气与对方交流。',
|
||
|
||
// 录音
|
||
recorderManager: null,
|
||
audioFilePath: '',
|
||
|
||
// 音频播放
|
||
audioContext: null,
|
||
|
||
// 语音活动检测(VAD)
|
||
vadEnabled: true,
|
||
vadVoiceThreshold: 0.004, // 0~1 之间的音量阈值,更灵敏
|
||
vadSilenceMs: 2500, // 静音判定时长
|
||
vadMaxDurationMs: 60000, // 单次录音最大时长
|
||
vadSpeaking: false,
|
||
vadSilenceStart: null,
|
||
vadMaxTimer: null,
|
||
recordingStartTime: null,
|
||
vadLastSoundTime: null,
|
||
vadLastFrameTime: null,
|
||
vadWatchTimer: null,
|
||
autoListen: true,
|
||
autoLoopTimer: null,
|
||
|
||
// 滚动
|
||
scrollTop: 0
|
||
};
|
||
},
|
||
|
||
onLoad(options) {
|
||
console.log('[VideoCall] 页面参数:', options);
|
||
|
||
// 从参数获取视频和音色信息
|
||
this.idleVideoUrl = decodeURIComponent(options.idleVideo || '');
|
||
this.talkingVideoUrl = decodeURIComponent(options.talkingVideo || this.idleVideoUrl);
|
||
this.voiceId = options.voiceId || '';
|
||
this.callerName = decodeURIComponent(options.callerName || '对方');
|
||
|
||
console.log('[VideoCall] 待机视频URL:', this.idleVideoUrl);
|
||
console.log('[VideoCall] 说话视频URL:', this.talkingVideoUrl);
|
||
console.log('[VideoCall] 音色ID:', this.voiceId);
|
||
|
||
// 下载视频到本地并转换路径
|
||
this.downloadAndConvertVideo();
|
||
|
||
this.startCallTimer();
|
||
this.initRecorder();
|
||
this.initAudioContext();
|
||
this.initVideoContext();
|
||
this.startAutoLoop();
|
||
},
|
||
|
||
onUnload() {
|
||
// 清理定时器
|
||
if (this.durationTimer) {
|
||
clearInterval(this.durationTimer);
|
||
}
|
||
if (this.vadMaxTimer) {
|
||
clearTimeout(this.vadMaxTimer);
|
||
this.vadMaxTimer = null;
|
||
}
|
||
if (this.vadWatchTimer) {
|
||
clearInterval(this.vadWatchTimer);
|
||
this.vadWatchTimer = null;
|
||
}
|
||
if (this.autoLoopTimer) {
|
||
clearInterval(this.autoLoopTimer);
|
||
this.autoLoopTimer = null;
|
||
}
|
||
// 清理音频
|
||
if (this.audioContext) {
|
||
this.audioContext.destroy();
|
||
}
|
||
},
|
||
|
||
methods: {
|
||
// 下载视频并转换本地路径
|
||
async downloadAndConvertVideo() {
|
||
if (!this.idleVideoUrl) {
|
||
uni.showToast({
|
||
title: '视频URL无效',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
|
||
console.log('[VideoCall] ========== 视频加载 ==========');
|
||
console.log('[VideoCall] 原始URL:', this.idleVideoUrl);
|
||
|
||
// 从配置获取 API_BASE,用于判断是否是服务器视频
|
||
const apiBaseUrl = new URL(API_BASE).hostname;
|
||
|
||
// 检查是否是本地服务器视频(包含 /static/videos/ 路径或包含 API_BASE 的域名)
|
||
const isLocalServerVideo = this.idleVideoUrl.includes('/static/videos/') ||
|
||
this.idleVideoUrl.includes(apiBaseUrl);
|
||
|
||
// 检查是否是外部视频链接(grsai.com等)
|
||
const isExternalVideo = this.idleVideoUrl.includes('grsai.com') ||
|
||
this.idleVideoUrl.includes('file49');
|
||
|
||
if (isExternalVideo) {
|
||
console.log('[VideoCall] 检测到外部视频链接,直接使用URL播放');
|
||
this.currentVideoUrl = this.idleVideoUrl;
|
||
this.talkingVideoUrl = this.idleVideoUrl;
|
||
console.log('[VideoCall] ========== 视频准备完成 ==========');
|
||
return;
|
||
}
|
||
|
||
if (isLocalServerVideo) {
|
||
console.log('[VideoCall] 检测到本地服务器视频,直接使用URL播放');
|
||
this.currentVideoUrl = this.idleVideoUrl;
|
||
this.talkingVideoUrl = this.idleVideoUrl;
|
||
console.log('[VideoCall] ========== 视频准备完成 ==========');
|
||
return;
|
||
}
|
||
|
||
// 本地服务器视频,尝试下载
|
||
console.log('[VideoCall] 本地服务器视频,开始下载...');
|
||
uni.showLoading({
|
||
title: '视频加载中...',
|
||
mask: true
|
||
});
|
||
|
||
// 下载视频到本地
|
||
uni.downloadFile({
|
||
url: this.idleVideoUrl,
|
||
success: (res) => {
|
||
console.log('[VideoCall] 下载响应状态码:', res.statusCode);
|
||
|
||
if (res.statusCode === 200) {
|
||
console.log('[VideoCall] ✅ 视频下载成功');
|
||
console.log('[VideoCall] 临时路径:', res.tempFilePath);
|
||
|
||
// #ifdef APP-PLUS
|
||
// APP环境:转换本地路径为UniApp可识别格式
|
||
if (typeof plus !== 'undefined' && plus.io) {
|
||
try {
|
||
console.log('[VideoCall] 开始转换路径...');
|
||
const convertedPath = plus.io.convertLocalFileSystemURL(res.tempFilePath);
|
||
console.log('[VideoCall] ✅ 路径转换成功');
|
||
console.log('[VideoCall] 转换后路径:', convertedPath);
|
||
|
||
this.currentVideoUrl = convertedPath;
|
||
this.idleVideoUrl = convertedPath;
|
||
this.talkingVideoUrl = convertedPath;
|
||
|
||
uni.hideLoading();
|
||
console.log('[VideoCall] ========== 视频准备完成 ==========');
|
||
} catch (err) {
|
||
console.error('[VideoCall] ❌ 路径转换失败:', err);
|
||
console.log('[VideoCall] 使用临时路径作为备选方案');
|
||
|
||
// 转换失败,直接使用临时路径
|
||
this.currentVideoUrl = res.tempFilePath;
|
||
this.idleVideoUrl = res.tempFilePath;
|
||
this.talkingVideoUrl = res.tempFilePath;
|
||
uni.hideLoading();
|
||
}
|
||
} else {
|
||
console.log('[VideoCall] plus.io 不可用,使用临时路径');
|
||
this.currentVideoUrl = res.tempFilePath;
|
||
this.idleVideoUrl = res.tempFilePath;
|
||
this.talkingVideoUrl = res.tempFilePath;
|
||
uni.hideLoading();
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
// 非APP环境,直接使用临时路径
|
||
console.log('[VideoCall] 非APP环境,使用临时路径');
|
||
this.currentVideoUrl = res.tempFilePath;
|
||
this.idleVideoUrl = res.tempFilePath;
|
||
this.talkingVideoUrl = res.tempFilePath;
|
||
uni.hideLoading();
|
||
// #endif
|
||
} else {
|
||
console.error('[VideoCall] ❌ 视频下载失败,状态码:', res.statusCode);
|
||
this.handleDownloadError();
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.error('[VideoCall] ❌ 视频下载失败:', err);
|
||
console.error('[VideoCall] 错误详情:', JSON.stringify(err));
|
||
this.handleDownloadError();
|
||
}
|
||
});
|
||
},
|
||
|
||
// 处理下载失败
|
||
handleDownloadError() {
|
||
uni.hideLoading();
|
||
|
||
console.log('[VideoCall] 下载失败,直接使用原URL播放');
|
||
this.currentVideoUrl = this.idleVideoUrl;
|
||
|
||
uni.showToast({
|
||
title: '将直接播放网络视频',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
},
|
||
|
||
// 初始化录音
|
||
initRecorder() {
|
||
this.recorderManager = uni.getRecorderManager();
|
||
|
||
this.recorderManager.onStart(() => {
|
||
console.log('[VideoCall] 开始录音');
|
||
});
|
||
|
||
this.recorderManager.onStop((res) => {
|
||
console.log('[VideoCall] 录音完成:', res.tempFilePath);
|
||
this.resetVADState();
|
||
|
||
const duration = res.duration || (Date.now() - (this.recordingStartTime || Date.now()));
|
||
if (duration < 400) {
|
||
console.warn('[VideoCall] 录音过短,忽略本次');
|
||
this.isProcessing = false;
|
||
return;
|
||
}
|
||
|
||
this.audioFilePath = res.tempFilePath;
|
||
this.processConversation();
|
||
});
|
||
|
||
// 帧回调用于VAD
|
||
this.recorderManager.onFrameRecorded && this.recorderManager.onFrameRecorded((res) => {
|
||
if (!this.vadEnabled || this.isProcessing) return;
|
||
this.handleVADFrame(res.frameBuffer);
|
||
});
|
||
|
||
this.recorderManager.onError((err) => {
|
||
console.error('[VideoCall] 录音失败:', err);
|
||
console.error('[VideoCall] 错误详情:', JSON.stringify(err));
|
||
this.isRecording = false;
|
||
this.resetVADState();
|
||
|
||
let errorMsg = '录音失败';
|
||
if (err.errMsg) {
|
||
if (err.errMsg.includes('permission')) {
|
||
errorMsg = '录音权限被拒绝,请在设置中允许录音权限';
|
||
} else if (err.errMsg.includes('busy')) {
|
||
errorMsg = '录音设备忙,请稍后再试';
|
||
} else {
|
||
errorMsg = `录音失败: ${err.errMsg}`;
|
||
}
|
||
}
|
||
|
||
uni.showModal({
|
||
title: '录音失败',
|
||
content: errorMsg + '\n\n请检查:\n1. 是否授予录音权限\n2. 麦克风是否被其他应用占用\n3. 设备是否支持录音',
|
||
showCancel: false,
|
||
confirmText: '知道了'
|
||
});
|
||
});
|
||
},
|
||
|
||
// 初始化视频控制器
|
||
initVideoContext() {
|
||
this.$nextTick(() => {
|
||
this.videoContext = uni.createVideoContext('videoPlayer', this);
|
||
console.log('[VideoCall] 视频控制器初始化完成');
|
||
});
|
||
},
|
||
|
||
// 初始化音频播放器
|
||
initAudioContext() {
|
||
this.audioContext = uni.createInnerAudioContext();
|
||
|
||
this.audioContext.onPlay(() => {
|
||
console.log('[VideoCall] 开始播放AI回复');
|
||
});
|
||
|
||
this.audioContext.onEnded(() => {
|
||
console.log('[VideoCall] AI回复播放完成');
|
||
// 恢复待机状态
|
||
this.isSpeaking = false;
|
||
this.isIdle = true;
|
||
});
|
||
|
||
this.audioContext.onError((err) => {
|
||
console.error('[VideoCall] 音频播放失败:', err);
|
||
this.isSpeaking = false;
|
||
this.isIdle = true;
|
||
});
|
||
},
|
||
|
||
// 开始通话计时
|
||
startCallTimer() {
|
||
this.callStartTime = Date.now();
|
||
this.durationTimer = setInterval(() => {
|
||
const elapsed = Math.floor((Date.now() - this.callStartTime) / 1000);
|
||
const minutes = Math.floor(elapsed / 60)
|
||
.toString()
|
||
.padStart(2, '0');
|
||
const seconds = (elapsed % 60).toString().padStart(2, '0');
|
||
this.callDuration = `${minutes}:${seconds}`;
|
||
}, 1000);
|
||
},
|
||
|
||
// 开始说话
|
||
startTalking() {
|
||
if (this.isProcessing) return;
|
||
|
||
this.resetVADState();
|
||
this.recordingStartTime = Date.now();
|
||
this.vadLastSoundTime = this.recordingStartTime;
|
||
this.vadLastFrameTime = this.recordingStartTime;
|
||
this.isRecording = true;
|
||
this.vadMaxTimer = setTimeout(() => {
|
||
this.stopTalkingFromVAD('max_duration');
|
||
}, this.vadMaxDurationMs);
|
||
this.vadWatchTimer = setInterval(() => {
|
||
if (!this.isRecording) return;
|
||
const now = Date.now();
|
||
const elapsed = now - (this.recordingStartTime || now);
|
||
|
||
// 如果没有帧回调,4s 兜底停止
|
||
if (now - (this.vadLastFrameTime || now) > 12000) {
|
||
this.stopTalkingFromVAD('no_frame');
|
||
return;
|
||
}
|
||
|
||
// 静音超时(需已检测到说话)
|
||
if (elapsed > 1500 && this.vadSpeaking && now - (this.vadLastSoundTime || now) > this.vadSilenceMs) {
|
||
this.stopTalkingFromVAD('silence');
|
||
return;
|
||
}
|
||
}, 300);
|
||
|
||
this.recorderManager.start({
|
||
format: 'mp3',
|
||
sampleRate: 16000,
|
||
numberOfChannels: 1,
|
||
encodeBitRate: 48000,
|
||
frameSize: 32 // KB,触发帧回调用于VAD
|
||
});
|
||
|
||
uni.showToast({
|
||
title: '正在聆听(自动识别说话)',
|
||
icon: 'none',
|
||
duration: 10000
|
||
});
|
||
},
|
||
|
||
// 停止说话
|
||
stopTalking() {
|
||
this.stopTalkingFromVAD('manual');
|
||
},
|
||
|
||
// VAD/手动统一停止
|
||
stopTalkingFromVAD(reason = 'vad') {
|
||
this.isRecording = false;
|
||
this.recorderManager.stop();
|
||
this.isProcessing = true;
|
||
this.processingText = '识别中...';
|
||
this.resetVADState();
|
||
|
||
console.log('[VideoCall] 停止录音,原因:', reason);
|
||
},
|
||
|
||
resetVADState() {
|
||
this.vadSpeaking = false;
|
||
this.vadSilenceStart = null;
|
||
this.vadLastSoundTime = null;
|
||
this.vadLastFrameTime = null;
|
||
if (this.vadMaxTimer) {
|
||
clearTimeout(this.vadMaxTimer);
|
||
this.vadMaxTimer = null;
|
||
}
|
||
if (this.vadWatchTimer) {
|
||
clearInterval(this.vadWatchTimer);
|
||
this.vadWatchTimer = null;
|
||
}
|
||
},
|
||
|
||
onDialectChange(e) {
|
||
this.selectedDialect = this.dialectOptions[e.detail.value] || '';
|
||
},
|
||
|
||
onLanguageHintChange(e) {
|
||
const label = this.languageHintOptions[e.detail.value] || '';
|
||
this.selectedLanguageHintLabel = label;
|
||
const match = label.match(/\(([^)]+)\)/);
|
||
this.selectedLanguageHint = match && match[1] ? match[1] : '';
|
||
},
|
||
|
||
handleVADFrame(frameBuffer) {
|
||
if (!frameBuffer || frameBuffer.byteLength === 0) return;
|
||
|
||
this.vadLastFrameTime = Date.now();
|
||
const volume = this.calculateRms(frameBuffer);
|
||
|
||
// 检测到声音
|
||
if (volume > this.vadVoiceThreshold) {
|
||
this.vadSpeaking = true;
|
||
this.vadSilenceStart = null;
|
||
this.vadLastSoundTime = Date.now();
|
||
return;
|
||
}
|
||
|
||
// 已在说话,出现静音
|
||
if (this.vadSpeaking) {
|
||
if (!this.vadSilenceStart) {
|
||
this.vadSilenceStart = Date.now();
|
||
} else if (Date.now() - this.vadSilenceStart > this.vadSilenceMs) {
|
||
this.stopTalkingFromVAD('silence');
|
||
}
|
||
}
|
||
},
|
||
|
||
calculateRms(frameBuffer) {
|
||
const dataView = new DataView(frameBuffer);
|
||
const len = dataView.byteLength / 2; // 16-bit
|
||
if (len === 0) return 0;
|
||
|
||
let sum = 0;
|
||
for (let i = 0; i < len; i++) {
|
||
const sample = dataView.getInt16(i * 2, true); // little-endian
|
||
sum += sample * sample;
|
||
}
|
||
|
||
return Math.sqrt(sum / len) / 32768; // 归一化
|
||
},
|
||
|
||
startAutoLoop() {
|
||
if (!this.autoListen) return;
|
||
if (this.autoLoopTimer) {
|
||
clearInterval(this.autoLoopTimer);
|
||
}
|
||
this.autoLoopTimer = setInterval(() => {
|
||
if (!this.autoListen) return;
|
||
// 空闲才自动开始
|
||
if (!this.isRecording && !this.isProcessing) {
|
||
this.startTalking();
|
||
}
|
||
}, 2000);
|
||
},
|
||
|
||
// 处理对话
|
||
async processConversation() {
|
||
try {
|
||
this.processingText = '正在识别...';
|
||
|
||
console.log('[VideoCall] 开始对话请求');
|
||
console.log('[VideoCall] 音频文件:', this.audioFilePath);
|
||
console.log('[VideoCall] 音色ID:', this.voiceId);
|
||
|
||
const userId = uni.getStorageSync('userId') || '';
|
||
const token = uni.getStorageSync('token') || '';
|
||
|
||
// 上传音频并处理对话
|
||
uni.uploadFile({
|
||
url: `${this.API_BASE}/api/conversation/talk`,
|
||
filePath: this.audioFilePath,
|
||
name: 'audio',
|
||
header: {
|
||
'X-User-Id': userId,
|
||
'Authorization': token ? `Bearer ${token}` : ''
|
||
},
|
||
formData: (() => {
|
||
const voiceId = this.voiceId || '';
|
||
const voiceType = (() => {
|
||
const v = (voiceId || '').trim();
|
||
if (!v) return 'CLONE';
|
||
const knownOfficial = ['Cherry','Kai','Mochi','Bunny','Jada','Dylan','Li','Marcus','Roy','Peter','Sunny','Eric','Rocky','Kiki'];
|
||
if (knownOfficial.includes(v)) return 'OFFICIAL';
|
||
if (v.startsWith('BV') || v.endsWith('_streaming') || v.endsWith('_offline') || v.endsWith('_bigtts')) return 'OFFICIAL';
|
||
return 'CLONE';
|
||
})();
|
||
const data = { voiceId, voiceType };
|
||
if (this.voiceId && this.voiceId.startsWith('cosyvoice-v3-plus-') && this.selectedDialect) {
|
||
data.dialect = this.selectedDialect;
|
||
}
|
||
if (this.voiceId && this.voiceId.startsWith('cosyvoice-v3-plus-') && this.selectedLanguageHint) {
|
||
data.languageHints = this.selectedLanguageHint;
|
||
}
|
||
return data;
|
||
})(),
|
||
success: (res) => {
|
||
console.log('[VideoCall] 对话响应状态码:', res.statusCode);
|
||
console.log('[VideoCall] 对话响应数据:', res.data);
|
||
|
||
try {
|
||
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||
|
||
if (result.success) {
|
||
console.log('[VideoCall] 对话成功');
|
||
console.log('[VideoCall] 识别文本:', result.recognizedText);
|
||
console.log('[VideoCall] AI回复:', result.aiResponse);
|
||
console.log('[VideoCall] 音频文件:', result.audioFile);
|
||
|
||
// 添加用户消息
|
||
this.addMessage('user', result.recognizedText);
|
||
|
||
// 添加AI回复
|
||
this.addMessage('ai', result.aiResponse);
|
||
|
||
// 播放AI回复
|
||
this.playAIResponse(result.audioFile);
|
||
} else {
|
||
console.error('[VideoCall] 对话失败:', result.message);
|
||
uni.showToast({
|
||
title: result.message || '对话失败',
|
||
icon: 'none'
|
||
});
|
||
this.isProcessing = false;
|
||
}
|
||
} catch (e) {
|
||
console.error('[VideoCall] JSON解析失败:', e);
|
||
console.error('[VideoCall] 原始响应:', res.data);
|
||
uni.showToast({
|
||
title: '服务器响应格式错误',
|
||
icon: 'none'
|
||
});
|
||
this.isProcessing = false;
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
console.error('[VideoCall] 对话失败:', err);
|
||
uni.showToast({
|
||
title: '对话失败',
|
||
icon: 'none'
|
||
});
|
||
this.isProcessing = false;
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('[VideoCall] 对话失败:', error);
|
||
uni.showToast({
|
||
title: '对话失败: ' + error.message,
|
||
icon: 'none'
|
||
});
|
||
this.isProcessing = false;
|
||
}
|
||
},
|
||
|
||
// 播放AI回复
|
||
playAIResponse(audioFile) {
|
||
this.processingText = '正在回复...';
|
||
|
||
// 标记AI正在说话(视频会在音频播放时自动播放)
|
||
this.isIdle = false;
|
||
this.isSpeaking = true;
|
||
|
||
// 播放音频(音频播放时会触发视频播放)
|
||
this.audioContext.src = `${this.API_BASE}/api/conversation/audio/${audioFile}`;
|
||
this.audioContext.play();
|
||
|
||
this.isProcessing = false;
|
||
},
|
||
|
||
// 添加消息
|
||
addMessage(role, content) {
|
||
const now = new Date();
|
||
const time = `${now.getHours()}:${now
|
||
.getMinutes()
|
||
.toString()
|
||
.padStart(2, '0')}`;
|
||
|
||
this.messages.push({
|
||
role,
|
||
content,
|
||
time
|
||
});
|
||
|
||
// 滚动到底部
|
||
this.$nextTick(() => {
|
||
this.scrollTop = 999999;
|
||
});
|
||
},
|
||
|
||
// 视频播放事件
|
||
onVideoPlay() {
|
||
console.log('[VideoCall] 视频开始播放');
|
||
this.isVideoLoading = false;
|
||
},
|
||
|
||
// 视频暂停事件
|
||
onVideoPause() {
|
||
console.log('[VideoCall] 视频暂停');
|
||
},
|
||
|
||
// 视频结束事件
|
||
onVideoEnded() {
|
||
console.log('[VideoCall] 视频播放结束');
|
||
if (!this.isIdle && this.isSpeaking) {
|
||
// 说话视频结束但音频还在播放,继续循环视频
|
||
console.log('[VideoCall] 视频循环播放');
|
||
}
|
||
},
|
||
|
||
// 视频加载成功
|
||
onVideoLoaded(e) {
|
||
console.log('[VideoCall] 视频元数据加载成功:', e);
|
||
this.isVideoLoading = false;
|
||
},
|
||
|
||
// 视频可以播放
|
||
onVideoCanPlay() {
|
||
console.log('[VideoCall] 视频可以播放');
|
||
this.isVideoLoading = false;
|
||
},
|
||
|
||
// 视频缓冲中
|
||
onVideoWaiting() {
|
||
console.log('[VideoCall] 视频缓冲中...');
|
||
this.isVideoLoading = true;
|
||
},
|
||
|
||
// 视频时间更新
|
||
onVideoTimeUpdate(e) {
|
||
if (!this.hasPlayedInitial && e.detail && e.detail.currentTime >= 1) {
|
||
console.log('[VideoCall] 视频已播放1秒,暂停等待AI回复');
|
||
this.hasPlayedInitial = true;
|
||
if (this.videoContext) {
|
||
this.videoContext.pause();
|
||
}
|
||
}
|
||
},
|
||
|
||
// 视频加载错误
|
||
onVideoError(e) {
|
||
console.error('[VideoCall] 视频加载失败:', e);
|
||
console.error('[VideoCall] 视频URL:', this.currentVideoUrl);
|
||
console.error('[VideoCall] 错误详情:', JSON.stringify(e));
|
||
|
||
this.isVideoLoading = false;
|
||
|
||
const errMsg = e.detail && e.detail.errMsg ? e.detail.errMsg : '未知错误';
|
||
const errCode = e.detail && e.detail.errCode ? e.detail.errCode : 'N/A';
|
||
|
||
uni.showModal({
|
||
title: '视频加载失败',
|
||
content: `错误代码: ${errCode}\n错误信息: ${errMsg}\n\n视频URL:\n${this.currentVideoUrl}\n\n请检查:\n1. URL是否有效\n2. 网络连接\n3. 视频格式是否支持\n4. HTTP权限配置`,
|
||
showCancel: false,
|
||
confirmText: '知道了'
|
||
});
|
||
},
|
||
|
||
// 挂断通话
|
||
hangUp() {
|
||
uni.showModal({
|
||
title: '结束通话',
|
||
content: '确定要结束通话吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
// 停止录音
|
||
if (this.isRecording) {
|
||
this.recorderManager.stop();
|
||
}
|
||
// 停止音频播放
|
||
if (this.audioContext) {
|
||
this.audioContext.stop();
|
||
}
|
||
// 返回上一页
|
||
uni.navigateBack();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.video-call-container {
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #000;
|
||
}
|
||
|
||
.header {
|
||
padding: 40rpx 40rpx 40rpx 24rpx; /* 左侧缩进,整体左移 */
|
||
padding-top: calc(40rpx + constant(safe-area-inset-top));
|
||
padding-top: calc(40rpx + env(safe-area-inset-top));
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.caller-name {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: white;
|
||
}
|
||
|
||
.call-duration {
|
||
font-size: 28rpx;
|
||
color: #4caf50;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.video-section {
|
||
flex: 1;
|
||
position: relative;
|
||
background: #000;
|
||
overflow: hidden;
|
||
z-index: 1;
|
||
}
|
||
|
||
.video-player {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.video-loading {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: rgba(0, 0, 0, 0.7);
|
||
padding: 30rpx 50rpx;
|
||
border-radius: 20rpx;
|
||
color: white;
|
||
font-size: 28rpx;
|
||
z-index: 20;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.speaking-indicator {
|
||
position: absolute;
|
||
top: 20rpx;
|
||
right: 20rpx;
|
||
background: rgba(76, 175, 80, 0.9);
|
||
padding: 16rpx 32rpx;
|
||
border-radius: 40rpx;
|
||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
|
||
z-index: 20;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.pulse {
|
||
color: white;
|
||
font-size: 24rpx;
|
||
font-weight: bold;
|
||
animation: pulse 1s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.chat-history {
|
||
height: 300rpx;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
padding: 20rpx 20rpx 20rpx 10rpx; /* 左侧缩进,内容左移 */
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.empty-hint {
|
||
text-align: center;
|
||
padding: 60rpx 0;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
margin-bottom: 20rpx;
|
||
animation: slideIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20rpx);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.user-message {
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.message-avatar {
|
||
font-size: 40rpx;
|
||
margin: 0 16rpx;
|
||
}
|
||
|
||
.message-content {
|
||
max-width: 70%;
|
||
padding: 20rpx;
|
||
border-radius: 16rpx;
|
||
background: #e3f2fd;
|
||
}
|
||
|
||
.user-message .message-content {
|
||
background: #c8e6c9;
|
||
}
|
||
|
||
.message-text {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.controls {
|
||
padding: 30rpx 30rpx 30rpx 14rpx; /* 左侧缩进,按钮左移 */
|
||
background: rgba(0, 0, 0, 0.8);
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.control-row {
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.control-row:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.talk-btn {
|
||
width: 100%;
|
||
padding: 32rpx;
|
||
background: #4caf50;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50rpx;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
box-shadow: 0 8rpx 16rpx rgba(76, 175, 80, 0.3);
|
||
}
|
||
|
||
.talk-btn.recording {
|
||
background: #f44336;
|
||
animation: pulse 1s infinite;
|
||
}
|
||
|
||
.talk-btn.processing {
|
||
background: #999;
|
||
}
|
||
|
||
.talk-btn[disabled] {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.hangup-btn {
|
||
width: 100%;
|
||
padding: 32rpx;
|
||
background: #f44336;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50rpx;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
box-shadow: 0 8rpx 16rpx rgba(244, 67, 54, 0.3);
|
||
}
|
||
</style>
|