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>
|