ai-clone/frontend-ai/pages/video-call-new/video-call-new.vue
2026-03-05 14:29:21 +08:00

3813 lines
100 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<view class="video-call-container" @touchend="handleGlobalTouchEnd" @touchcancel="handleGlobalTouchCancel">
<!-- 顶部导航 -->
<view class="header">
<text class="header-title">视频通话</text>
<view class="placeholder"></view>
</view>
<!-- 选择视频阶段 -->
<view v-if="!callStarted" class="select-video-section">
<!-- 加载状态 -->
<view v-if="loading" class="loading-box">
<text class="loading-text">⏳ 加载视频列表...</text>
</view>
<!-- 视频列表 -->
<scroll-view v-else-if="videos.length > 0" scroll-y class="video-list">
<view class="list-header">
<text class="list-title">选择要通话的视频</text>
<text class="list-count">共 {{ videos.length }} 个</text>
</view>
<view
v-for="video in videos"
:key="video.id"
class="video-item"
@click="startVideoCall(video)"
>
<!-- 左侧封面 -->
<view class="item-cover">
<image
v-if="video.photo_url"
:src="video.photo_url"
class="cover-img"
mode="aspectFill"
></image>
<view v-else class="cover-placeholder">
<text class="placeholder-icon">📹</text>
</view>
<!-- 隐藏的视频预加载组件 -->
<video
v-if="video.edited_video_url || video.videoUrl || video.local_video_path || video.video_url || video.localVideoPath"
:id="'preload-video-' + video.id"
:src="video.edited_video_url || video.videoUrl || video.local_video_path || video.video_url || video.localVideoPath"
class="preload-video"
:muted="true"
:autoplay="false"
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:enable-progress-gesture="false"
@loadedmetadata="onVideoPreloaded(video.id)"
></video>
</view>
<!-- 中间信息 -->
<view class="item-info">
<view class="info-name-row">
<text class="info-name">{{ video.name || '复活视频' }}</text>
<!-- 预加载状态标识 -->
<view v-if="videoPreloadStatus[video.id]" class="cache-badge">
<text class="cache-badge-text completed">✓ 已就绪</text>
</view>
<!-- 缓存状态标识 -->
<view v-else-if="cacheProgress[video.id]" class="cache-badge">
<text v-if="cacheProgress[video.id].status === 'completed'" class="cache-badge-text completed">✓ 已缓存</text>
<text v-else-if="cacheProgress[video.id].status === 'downloading'" class="cache-badge-text downloading">{{ cacheProgress[video.id].progress }}%</text>
</view>
</view>
<text class="info-text">{{ video.text || '暂无描述' }}</text>
</view>
<!-- 右侧按钮 -->
<view class="item-action">
<view class="call-icon">
<text class="icon-text">📞</text>
</view>
</view>
</view>
</scroll-view>
<!-- 空状态 -->
</view>
<!-- 通话中阶段 -->
<view v-else class="call-active-section">
<!-- 接通中遮罩层 -->
<view v-if="isConnecting" class="connecting-overlay">
<!-- 背景模糊头像 -->
<view class="connecting-bg">
<image
v-if="selectedPhotoUrl"
:src="selectedPhotoUrl"
class="bg-avatar"
mode="aspectFill"
></image>
</view>
<!-- 接通中内容 -->
<view class="connecting-content">
<!-- 头像 -->
<view class="avatar-container">
<image
v-if="selectedPhotoUrl"
:src="selectedPhotoUrl"
class="avatar-img"
mode="aspectFill"
></image>
<view v-else class="avatar-placeholder">📹</view>
</view>
<!-- 名称 -->
<text class="connecting-name">{{ selectedVideoName }}</text>
<!-- 状态文字 -->
<view class="connecting-status">
<view class="status-dots">
<view class="dot dot-1"></view>
<view class="dot dot-2"></view>
<view class="dot dot-3"></view>
</view>
<text class="status-text">{{ connectingText }}</text>
</view>
</view>
</view>
<!-- 全屏视频背景 -->
<view class="video-fullscreen" :class="{ 'video-hidden': isConnecting }">
<video
id="callVideo"
v-if="selectedVideoUrl"
:src="localVideoUrl || selectedVideoUrl"
class="video-player"
:loop="true"
:muted="true"
:autoplay="false"
:controls="false"
:show-center-play-btn="false"
:show-play-btn="false"
:show-fullscreen-btn="false"
:show-progress="false"
:enable-progress-gesture="false"
preload="auto"
object-fit="cover"
@error="onVideoError"
@loadeddata="onVideoLoaded"
@loadedmetadata="onVideoMetadataLoaded"
@play="onVideoPlay"
@pause="onVideoPause"
></video>
<view v-else class="video-placeholder">
<text class="placeholder-icon">📹</text>
<text class="placeholder-text">视频加载中...</text>
</view>
</view>
<!-- 顶部状态栏 -->
<view class="call-status-bar">
<view class="status-info">
<text class="call-name">{{ selectedVideoName }}</text>
<text class="call-time">{{ callDuration }}</text>
<view
v-if="isRecording || isProcessing"
class="listening-chip listening-active"
>
<view class="listening-icon">💗</view>
<text class="listening-text">TA在听你说</text>
</view>
</view>
</view>
<!-- 底部控制区 -->
<view class="call-controls">
<view v-if="selectedVoiceId && selectedVoiceId.startsWith('cosyvoice-v3-plus-')" class="form-section" style="position: relative; z-index: 1002;">
<view class="form-label">方言</view>
<picker mode="selector" :range="dialectOptions" @change="onDialectChange">
<view class="picker-large">
{{ selectedDialect || '请选择方言(可选)' }}
</view>
</picker>
</view>
<view v-if="selectedVoiceId && selectedVoiceId.startsWith('cosyvoice-v3-plus-')" class="form-section" style="position: relative; z-index: 1002;">
<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-buttons" style="position: relative; z-index: 1002;">
<!-- 清空历史 -->
<view class="control-btn" style="pointer-events: auto; z-index: 1003;" @tap="handleClearHistory">
<view class="btn-circle">
<view class="icon-delete"></view>
</view>
<text class="btn-label">清空</text>
</view>
<!-- 录音按钮 -->
<view
class="control-btn mic-btn"
style="pointer-events: auto; z-index: 1003;"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
>
<view class="speak-button" :class="{ 'recording': isRecording, 'processing': isProcessing }">
<!-- 外圈动画 -->
<view v-if="isRecording" class="speak-ring"></view>
<view v-if="isRecording" class="speak-ring-outer"></view>
<!-- 按钮主体 -->
<view class="speak-button-inner">
<view v-if="!isRecording && !isProcessing" class="icon-mic-new"></view>
<view v-if="isRecording" class="icon-wave-container">
<view class="wave-bar" style="animation-delay: 0s;"></view>
<view class="wave-bar" style="animation-delay: 0.1s;"></view>
<view class="wave-bar" style="animation-delay: 0.2s;"></view>
<view class="wave-bar" style="animation-delay: 0.15s;"></view>
<view class="wave-bar" style="animation-delay: 0.05s;"></view>
</view>
<view v-if="isProcessing" class="icon-loading-new"></view>
</view>
</view>
<text class="btn-label-new" :class="{ 'active': isRecording }">
{{ isRecording ? '松开发送' : isProcessing ? '聆听中...' : '长按说话' }}
</text>
</view>
<!-- 挂断 -->
<view class="control-btn" style="pointer-events: auto; z-index: 1003;" @tap="endCall">
<view class="btn-circle end-call">
<view class="icon-hangup"></view>
</view>
<text class="btn-label">挂断</text>
</view>
</view>
</view>
</view>
<!-- 通话设置弹窗 -->
<view v-if="showSettingsDialog" class="settings-dialog-mask" @click="closeSettingsDialog">
<view class="settings-dialog" @click.stop>
<view class="dialog-header">
<text class="dialog-title">💭 通话设置</text>
<text class="dialog-close" @click="closeSettingsDialog">✕</text>
</view>
<view class="dialog-content">
<!-- 记忆设定 -->
<view class="dialog-section">
<view class="section-label">💭 记忆设定(可选)</view>
<text class="section-hint">设置对话记忆让AI更真实地模拟对方</text>
<view class="memory-item">
<text class="memory-label">身份</text>
<input
class="memory-input"
v-model="dialogMemoryIdentity"
placeholder="例如:我的母亲"
maxlength="50"
/>
</view>
<view class="memory-item">
<text class="memory-label">性格特点</text>
<textarea
class="memory-textarea"
v-model="dialogMemoryInfo"
placeholder="例如:温柔体贴,喜欢养花,做饭很拿手..."
maxlength="200"
/>
</view>
</view>
</view>
<view class="dialog-footer">
<button class="dialog-btn cancel" @click="closeSettingsDialog">取消</button>
<button class="dialog-btn confirm" @click="confirmSettings">开始通话</button>
</view>
</view>
</view>
<!-- 支付弹窗 -->
<PaymentModal
ref="paymentModal"
:show="paymentModalData.show"
:serviceType="paymentModalData.serviceType"
:serviceName="paymentModalData.serviceName"
:serviceDesc="paymentModalData.serviceDesc"
:price="paymentModalData.price"
:orderNo="paymentModalData.orderNo"
:paymentTips="paymentModalData.paymentTips"
@close="handlePaymentClose"
@confirm="handlePaymentConfirm"
/>
</view>
</template>
<script>
import { API_BASE, API_ENDPOINTS, buildURL } from '@/config/api.js';
import PaymentModal from '@/components/PaymentModal.vue';
import { SERVICE_TYPES, showPaymentModal, handlePaymentConfirm as processPayment } from '@/utils/payment.js';
export default {
components: {
PaymentModal
},
data() {
return {
API_BASE,
// 页面加载参数
loadOptions: null,
// 用户ID缓存用于检测用户切换
lastUserId: null,
// 视频选择
loading: false,
videos: [],
selectedVideoId: '',
selectedVideoName: '',
selectedVideoUrl: '',
selectedPhotoUrl: '',
selectedVoiceId: '',
selectedDialect: '',
selectedLanguageHint: '',
selectedLanguageHintLabel: '',
languageHintOptions: ['中文(zh)', '英文(en)', '法语(fr)', '德语(de)', '日语(ja)', '韩语(ko)', '俄语(ru)'],
dialectOptions: ['广东话', '东北话', '甘肃话', '贵州话', '河南话', '湖北话', '江西话', '闽南话', '宁夏话', '山西话', '陕西话', '山东话', '上海话', '四川话', '天津话', '云南话'],
// 接通中状态
isConnecting: false,
connectingText: '正在接通...',
// 视频缓存
videoCache: {},
downloadingVideos: {},
localVideoUrl: '',
// 缓存进度跟踪
cacheProgress: {}, // { videoId: { progress: 0-100, status: 'downloading'|'completed'|'failed' } }
preCachingInProgress: false,
// 视频预加载状态
videoPreloadStatus: {}, // { videoId: true/false }
// 通话状态
callStarted: false,
callStatus: '视频通话中',
callDuration: '00:00',
callStartTime: null,
durationTimer: null,
// 对话
messages: [],
recognizingText: '', // 实时识别的文本partial/final
scrollTop: 0,
// 录音
isRecording: false,
isProcessing: false,
isSpeaking: false, // AI是否正在说话
processingText: '正在聆听...',
recorderManager: null,
audioFilePath: '',
recordingStartTime: null,
autoListen: false, // 禁用自动录音
autoLoopTimer: null,
longPressTimer: null, // 长按定时器
isTouching: false, // 是否正在触摸
stopFallbackTimer: null,
stopFallbackRetries: 0,
stopFallbackActive: false,
// 语音活动检测VAD
vadEnabled: true,
vadVoiceThreshold: 0.004,
vadSilenceMs: 600, // 静音0.6s自动停,快速响应
vadMaxDurationMs: 15000, // 单次最长录音15s避免截断长句
vadSpeaking: false,
vadSilenceStart: null,
vadMaxTimer: null,
vadLastSoundTime: null,
vadLastFrameTime: null,
vadWatchTimer: null,
// 音频播放
audioContext: null,
audioCacheKey: '',
currentAudioSrc: '',
pendingAudioUrl: '',
audioTriedFallback: false,
audioSessionId: 0,
audioPlayedInSession: false,
audioPlayStartAt: 0,
audioTriedRedownload: false,
audioTriedRecreateContext: false,
audioPlayInvokedInSession: false,
audioPlayTargetSessionId: 0,
audioDiagnosedInSession: false,
videoContext: null,
audioContextActivated: false, // 音频上下文是否已激活
videoInitialPlayed: false, // 视频是否已进行初始播放(避免黑屏)
// 超时控制
processingTimeout: null,
// 设置弹窗
showSettingsDialog: false,
dialogMemoryIdentity: '',
dialogMemoryInfo: '',
dialogMemoryCatchphrase: '',
systemPrompt: '', // 保存用户设置的系统提示词
// 支付相关
paymentModalData: {
show: false,
serviceType: '',
serviceName: '',
serviceDesc: '',
price: 0,
orderNo: '',
paymentTips: '点击确认支付后将开始视频通话'
},
_paymentResolve: null,
_paymentReject: null,
_paymentOnSuccess: null,
_paymentOnFailed: null
};
},
async onLoad(options) {
console.log('[VideoCallNew] 页面加载', options);
// 保存 options 供 onShow 使用
this.loadOptions = options;
// 记录当前用户ID
const userId = uni.getStorageSync('userId');
this.lastUserId = userId;
// 提前加载缓存信息
await this.loadVideoCache();
// 如果有URL参数直接进入通话模式
if (options && options.videoId) {
this.selectedVideoId = options.videoId;
this.selectedVideoName = decodeURIComponent(options.videoName || '复活视频');
this.selectedVideoUrl = decodeURIComponent(options.videoUrl || '');
this.selectedVoiceId = options.voiceId || '';
this.selectedDialect = '';
console.log('[VideoCallNew] 从URL参数加载视频信息');
console.log('[VideoCallNew] 视频ID:', this.selectedVideoId);
console.log('[VideoCallNew] 视频名称:', this.selectedVideoName);
console.log('[VideoCallNew] 视频URL:', this.selectedVideoUrl);
console.log('[VideoCallNew] 音色ID:', this.selectedVoiceId);
// 检查是否已有缓存
await this.checkAndLoadLocalVideo(this.selectedVideoId, this.selectedVideoUrl);
// 初始化
this.initRecorder();
this.initAudioContext();
this.initVideoContext();
this.startAutoLoop();
// 直接显示支付弹窗
setTimeout(() => {
this.startVideoCallDirect();
}, 300);
} else {
// 正常模式,加载视频列表
this.loadVideos();
this.initRecorder();
this.initAudioContext();
this.initVideoContext();
this.startAutoLoop();
}
},
onShow() {
console.log('[VideoCallNew] 页面显示');
// 检查用户是否切换
const currentUserId = uni.getStorageSync('userId');
const userChanged = currentUserId !== this.lastUserId;
if (userChanged) {
console.log('[VideoCallNew] 检测到用户切换');
console.log('[VideoCallNew] 上次用户ID:', this.lastUserId, '当前用户ID:', currentUserId);
this.lastUserId = currentUserId;
}
// 每次页面显示时都刷新视频列表只要不在通话中且不是从URL参数直接进入
// 这样点击底部导航栏的"AI通话"时就会自动刷新
if (!this.callStarted && (!this.loadOptions || !this.loadOptions.videoId)) {
console.log('[VideoCallNew] 页面重新显示,刷新视频列表');
this.loadVideos();
}
},
onUnload() {
this.cleanup();
},
methods: {
isVideoUsableForCall(video) {
if (!video) return false;
const videoUrl = video.edited_video_url || video.videoUrl || video.local_video_path || video.video_url || video.localVideoPath;
const voiceId = video.voice_id || video.voiceId;
return !!videoUrl && !!voiceId;
},
// 加载视频列表
async loadVideos() {
this.loading = true;
// 获取用户ID和Token
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
// 加载缓存信息
await this.loadVideoCache();
uni.request({
url: `${this.API_BASE}/api/photo-revival/videos`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[VideoCallNew] 视频列表响应:', res.data);
if (res.statusCode === 200) {
// 处理不同的数据格式
let videoList = [];
if (Array.isArray(res.data)) {
videoList = res.data;
} else if (res.data && Array.isArray(res.data.data)) {
videoList = res.data.data;
} else if (res.data && res.data.videos && Array.isArray(res.data.videos)) {
videoList = res.data.videos;
} else if (res.data && res.data.success && Array.isArray(res.data.videos)) {
videoList = res.data.videos;
}
this.videos = (videoList || []).filter(v => this.isVideoUsableForCall(v));
console.log('[VideoCallNew] 加载视频列表成功:', this.videos.length);
}
},
fail: (err) => {
console.error('[VideoCallNew] 加载视频列表失败:', err);
uni.showToast({
title: '加载失败',
icon: 'none'
});
},
complete: () => {
this.loading = false;
}
});
},
// 选择视频
selectVideo(video) {
this.selectedVideoId = video.id;
this.selectedVideoName = video.name || '复活视频';
// 兼容多种字段名格式
this.selectedVideoUrl = video.edited_video_url || video.videoUrl || video.local_video_path || video.video_url || video.localVideoPath;
this.selectedPhotoUrl = video.photoUrl || video.photo_url || video.photoPath || '';
this.selectedVoiceId = video.voice_id || video.voiceId;
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
console.log('[VideoCallNew] 选择视频:', this.selectedVideoId);
console.log('[VideoCallNew] 视频URL:', this.selectedVideoUrl);
console.log('[VideoCallNew] 照片URL:', this.selectedPhotoUrl);
console.log('[VideoCallNew] 音色ID:', this.selectedVoiceId);
},
// 开始视频通话(从列表点击)
async startVideoCall(video) {
// 先选择视频
this.selectVideo(video);
if (!this.selectedVideoId || !this.selectedVoiceId) {
uni.showToast({
title: '该视频无法进行通话',
icon: 'none'
});
return;
}
if (!this.selectedVideoUrl) {
uni.showToast({
title: '视频地址不存在',
icon: 'none'
});
return;
}
console.log('[VideoCallNew] 准备视频通话视频ID:', this.selectedVideoId);
console.log('[VideoCallNew] 视频URL:', this.selectedVideoUrl);
// 检查并加载本地缓存
await this.checkAndLoadLocalVideo(this.selectedVideoId, this.selectedVideoUrl);
// 先进行支付
showPaymentModal(
this,
SERVICE_TYPES.VIDEO_CALL.type,
() => {
// 支付成功后显示设置弹窗
this.showCallSettings();
},
(error) => {
console.error('[Payment] 支付失败:', error);
}
);
},
// 直接开始视频通话从URL参数进入
async startVideoCallDirect() {
if (!this.selectedVideoId || !this.selectedVoiceId) {
uni.showToast({
title: '视频信息不完整',
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
return;
}
if (!this.selectedVideoUrl) {
uni.showToast({
title: '视频地址不存在',
icon: 'none'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
return;
}
console.log('[VideoCallNew] 直接开始视频通话');
console.log('[VideoCallNew] 视频ID:', this.selectedVideoId);
console.log('[VideoCallNew] 当前使用URL:', this.localVideoUrl || this.selectedVideoUrl);
// 先进行支付
showPaymentModal(
this,
SERVICE_TYPES.VIDEO_CALL.type,
() => {
// 支付成功后显示设置弹窗
this.showCallSettings();
},
(error) => {
console.error('[Payment] 支付失败:', error);
// 支付失败返回上一页
setTimeout(() => {
uni.navigateBack();
}, 1000);
}
);
},
// 显示通话设置弹窗
showCallSettings() {
// 初始化弹窗数据
this.dialogMemoryIdentity = '';
this.dialogMemoryInfo = '';
this.dialogMemoryCatchphrase = '';
// 显示设置弹窗
this.showSettingsDialog = true;
},
// 关闭设置弹窗
closeSettingsDialog() {
this.showSettingsDialog = false;
},
// 确认设置并开始通话
confirmSettings() {
console.log('[VideoCallNew] 开始视频通话');
console.log('[VideoCallNew] 使用视频的音色ID:', this.selectedVoiceId);
// 关闭弹窗
this.showSettingsDialog = false;
// 重置视频初始播放标志
this.videoInitialPlayed = false;
// 立即显示接通中状态不使用loading使用自定义接通界面
this.isConnecting = true;
this.connectingText = '正在接通...';
// 构建系统提示词并保存与复活页一致的Role Play格式
if (this.dialogMemoryIdentity || this.dialogMemoryInfo) {
if (this.dialogMemoryIdentity) {
let prompt = `你是${this.dialogMemoryIdentity},你已经去世。用户正在和你对话。`;
prompt += '\n\n你的性格特点\n\n亲切、真实、克制';
prompt += '\n\n你的行事风格\n\n直接回答不绕弯';
prompt += '\n\n你的语言特点\n\n自然口语语气温和';
if (this.dialogMemoryInfo) {
prompt += `\n\n补充设定请严格遵循\n\n${this.dialogMemoryInfo}`;
}
prompt += '\n\n注意不要使用括号包括()和())来描述动作、心理、背景。';
prompt += '\n\n规则\n\n1. 不要虚构不存在的事,不要编造记忆或经历。\n2. 不要回忆以前(除非用户明确提到并且你能基于已有设定回答)。\n3. 不要使用任何称呼(因为用户可能是任何身份)。\n4. 不要使用emoji/颜文字/特殊符号。\n5. 顺应用户话题,亲切回答问题。\n6. 时刻记住自己是逝者。\n7. 每次回复保持在20~50字之间。';
this.systemPrompt = prompt;
} else {
let prompt = '你是一位已经去世的人。用户正在和你对话。';
prompt += '\n\n你的性格特点\n\n亲切、真实、克制';
prompt += '\n\n你的行事风格\n\n直接回答不绕弯';
prompt += '\n\n你的语言特点\n\n自然口语语气温和';
if (this.dialogMemoryInfo) {
prompt += `\n\n补充设定请严格遵循\n\n${this.dialogMemoryInfo}`;
}
prompt += '\n\n注意不要使用括号包括()和())来描述动作、心理、背景。';
prompt += '\n\n规则\n\n1. 不要虚构不存在的事,不要编造记忆或经历。\n2. 不要回忆以前(除非用户明确提到并且你能基于已有设定回答)。\n3. 不要使用任何称呼(因为用户可能是任何身份)。\n4. 不要使用emoji/颜文字/特殊符号。\n5. 顺应用户话题,亲切回答问题。\n6. 时刻记住自己是逝者。\n7. 每次回复保持在20~50字之间。';
this.systemPrompt = prompt;
}
} else {
let prompt = '你是一位已经去世的人。用户正在和你对话。';
prompt += '\n\n你的性格特点\n\n亲切、真实、克制';
prompt += '\n\n你的行事风格\n\n直接回答不绕弯';
prompt += '\n\n你的语言特点\n\n自然口语语气温和';
prompt += '\n\n注意不要使用括号包括()和())来描述动作、心理、背景。';
prompt += '\n\n规则\n\n1. 不要虚构不存在的事,不要编造记忆或经历。\n2. 不要回忆以前(除非用户明确提到并且你能基于已有设定回答)。\n3. 不要使用任何称呼(因为用户可能是任何身份)。\n4. 不要使用emoji/颜文字/特殊符号。\n5. 顺应用户话题,亲切回答问题。\n6. 时刻记住自己是逝者。\n7. 每次回复保持在20~50字之间。';
this.systemPrompt = prompt;
}
console.log('[VideoCallNew] 系统提示词:', this.systemPrompt);
// 立即开始通话
this.callStarted = true;
this.startCallTimer();
// 等待DOM更新后重新初始化视频上下文
this.$nextTick(() => {
// 重新初始化视频上下文因为DOM重新渲染了
this.videoContext = uni.createVideoContext('callVideo', this);
console.log('[VideoCallNew] 重新初始化视频上下文');
console.log('[VideoCallNew] 当前视频URL:', this.localVideoUrl || this.selectedVideoUrl);
// #ifndef MP-WEIXIN
// 非微信小程序:可做短暂自动播放避免黑屏
setTimeout(() => {
if (this.videoContext && !this.videoInitialPlayed) {
console.log('[VideoCallNew] 视频上下文已初始化,自动播放一秒避免黑屏');
this.videoInitialPlayed = true;
this.videoContext.seek(0);
this.videoContext.play();
// 播放一秒后暂停
setTimeout(() => {
if (this.videoContext && !this.isSpeaking) {
this.videoContext.pause();
console.log('[VideoCallNew] 视频已播放一秒暂停等待AI回复');
}
}, 1000);
}
}, 500);
// #endif
});
},
// 开始计时
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}`;
// 检查是否达到10分钟600秒
if (elapsed >= 600) {
console.log('[VideoCallNew] 通话时长已达10分钟自动挂断');
this.autoEndCall();
}
}, 1000);
},
// 初始化录音
initRecorder() {
this.recorderManager = uni.getRecorderManager();
console.log('[VAD] RecorderManager 对象:', this.recorderManager);
console.log('[VAD] onFrameRecorded 方法存在:', typeof this.recorderManager.onFrameRecorded);
this.recorderManager.onStart(() => {
console.log('[VideoCallNew] 开始录音');
});
this.recorderManager.onStop((res) => {
console.log('[VideoCallNew] 录音完成:', res.tempFilePath);
this.isRecording = false;
this.isTouching = false;
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (this.stopFallbackTimer) {
clearTimeout(this.stopFallbackTimer);
this.stopFallbackTimer = null;
}
this.stopFallbackActive = false;
this.stopFallbackRetries = 0;
this.resetVADState();
const duration = res.duration || (Date.now() - (this.recordingStartTime || Date.now()));
if (duration < 400) {
console.warn('[VideoCallNew] 录音过短,已忽略');
this.isProcessing = false;
this.callStatus = '通话中';
return;
}
this.audioFilePath = res.tempFilePath;
this.processConversation();
});
// 语音活动检测
if (typeof this.recorderManager.onFrameRecorded === 'function') {
console.log('[VAD] ✅ onFrameRecorded 支持,已绑定帧回调');
this.recorderManager.onFrameRecorded((res) => {
console.log('[VAD] 🎯 收到帧数据!大小:', res.frameBuffer ? res.frameBuffer.byteLength : 0, 'bytes');
if (!this.vadEnabled || this.isProcessing) {
console.log('[VAD] VAD已禁用或正在处理跳过');
return;
}
this.handleVADFrame(res.frameBuffer);
});
} else {
console.error('[VAD] ❌ onFrameRecorded 不支持VAD功能将无法使用');
console.error('[VAD] recorderManager 类型:', typeof this.recorderManager);
console.error('[VAD] 可用方法:', Object.keys(this.recorderManager));
this.vadEnabled = false;
}
this.recorderManager.onError((err) => {
console.error('[VideoCallNew] 录音失败:', err);
this.isRecording = false;
this.resetVADState();
// 判断是否为权限问题
const isPermissionError = err.errMsg && (
err.errMsg.includes('permission') ||
err.errMsg.includes('authorize') ||
err.errMsg.includes('denied') ||
err.errMsg.includes('占用')
);
if (isPermissionError) {
// 权限问题,引导用户去设置
uni.showModal({
title: '需要麦克风权限',
content: '录音功能需要麦克风权限才能使用。\n\n请前往\n手机设置 → 应用管理 → 时光意境 → 权限管理 → 开启麦克风权限',
confirmText: '我知道了',
showCancel: false
});
} else {
uni.showToast({
title: '录音失败: ' + (err.errMsg || '未知错误'),
icon: 'none',
duration: 3000
});
}
});
},
// 初始化视频播放
initVideoContext() {
this.videoContext = uni.createVideoContext('callVideo', this);
},
// 初始化音频播放(创建一次并复用)
initAudioContext() {
if (this.audioContext) return;
this.audioContext = uni.createInnerAudioContext();
// 设置最大音量
this.audioContext.volume = 1.0;
// 设置不遵循静音开关
try {
this.audioContext.obeyMuteSwitch = false;
} catch (e) {
console.warn('[VideoCallNew] 设置obeyMuteSwitch失败:', e);
}
console.log('[VideoCallNew] 🔊 音频上下文初始化完成,音量设置为最大');
this.audioContext.onCanplay(() => {
console.log('[VideoCallNew] ✅ 音频已准备好,可以播放');
this.tryPlayAudioForSession('canplay');
});
this.audioContext.onEnded(() => {
console.log('[VideoCallNew] AI回复播放完成');
this.isSpeaking = false;
this.callStatus = '通话中';
if (this.videoContext) {
// 结束后暂停即可,避免每次重置触发重缓冲
this.videoContext.pause();
}
});
this.audioContext.onError((err) => {
if (this.isSpeaking && !this.audioDiagnosedInSession && this.pendingAudioUrl) {
this.audioDiagnosedInSession = true;
this.diagnoseAudioUrl(this.pendingAudioUrl);
}
// 远程直连播放偶发会被系统 stop 或解码失败,先尝试回退到本地缓存/临时文件重试一次
const isDecodeError = !!(err && (err.errCode === 67 || err.errMsg && String(err.errMsg).includes('decode')));
const isCurrentlyRemote = !!(this.pendingAudioUrl && (this.currentAudioSrc === this.pendingAudioUrl || this.audioContext.src === this.pendingAudioUrl));
if (this.isSpeaking && !this.audioTriedFallback && isDecodeError && isCurrentlyRemote && this.pendingAudioUrl && this.audioContext) {
this.audioTriedFallback = true;
console.warn('[VideoCallNew] 音频播放失败尝试回退本地重试一次。errCode=', err.errCode, 'errMsg=', err.errMsg);
const sessionId = this.audioSessionId;
this.downloadAudioTemp(this.pendingAudioUrl).then((localSrc) => {
if (sessionId !== this.audioSessionId) return;
if (!localSrc || !this.audioContext || !this.isSpeaking) return;
if (localSrc === this.pendingAudioUrl) return;
if (localSrc === this.currentAudioSrc || localSrc === this.audioContext.src) return;
try {
this.audioContext.stop();
} catch (e) {
// ignore
}
this.currentAudioSrc = localSrc;
this.audioContext.src = localSrc;
setTimeout(() => {
if (sessionId !== this.audioSessionId) return;
if (!this.audioContext || !this.isSpeaking) return;
try {
this.audioContext.play();
console.log('[VideoCallNew] ✅ 已使用本地音频回退重试播放');
} catch (e) {
// 继续走下面的错误处理
}
}, 150);
});
return;
}
// decode fail 可能会把解码器/上下文打坏:重建 InnerAudioContext 后再用当前 src 重试一次
if (this.isSpeaking && isDecodeError && !this.audioTriedRecreateContext && this.audioContext) {
this.audioTriedRecreateContext = true;
const sessionId = this.audioSessionId;
const retrySrc = this.currentAudioSrc || (this.audioContext && this.audioContext.src) || this.pendingAudioUrl;
console.warn('[VideoCallNew] decode fail尝试重建音频上下文并重试一次');
this.recreateAudioContext().then(() => {
if (sessionId !== this.audioSessionId) return;
if (!this.audioContext || !this.isSpeaking || !retrySrc) return;
this.audioContext.src = retrySrc;
this.currentAudioSrc = retrySrc;
setTimeout(() => {
if (sessionId !== this.audioSessionId) return;
if (!this.audioContext || !this.isSpeaking) return;
try {
this.audioContext.play();
console.log('[VideoCallNew] ✅ 已重建音频上下文并重试播放');
} catch (e) {
// 继续走下面错误处理
}
}, 200);
});
return;
}
// 某些机型对 savedFilePath(wxfile://store_) 解码偶发失败:清缓存并重新下载 tempFilePath(wxfile://tmp_) 播放一次
const isCachedStoreFile = !!(this.audioContext && this.audioContext.src && String(this.audioContext.src).startsWith('wxfile://store_'));
if (this.isSpeaking && isDecodeError && !this.audioTriedRedownload && isCachedStoreFile && this.pendingAudioUrl && this.audioContext) {
this.audioTriedRedownload = true;
const sessionId = this.audioSessionId;
console.warn('[VideoCallNew] 本地缓存音频解码失败,清缓存并重新下载临时文件重试一次');
this.clearAudioCacheForUrl(this.pendingAudioUrl).finally(() => {
this.downloadAudioTemp(this.pendingAudioUrl).then((tempSrc) => {
if (sessionId !== this.audioSessionId) return;
if (!tempSrc || !this.audioContext || !this.isSpeaking) return;
try {
this.audioContext.stop();
} catch (e) {
// ignore
}
this.currentAudioSrc = tempSrc;
this.audioContext.src = tempSrc;
setTimeout(() => {
if (sessionId !== this.audioSessionId) return;
if (!this.audioContext || !this.isSpeaking) return;
try {
this.audioContext.play();
console.log('[VideoCallNew] ✅ 已使用重新下载的临时音频重试播放');
} catch (e) {
// 继续走下面错误处理
}
}, 200);
});
});
return;
}
console.error('[VideoCallNew] ❌ 音频播放失败:', JSON.stringify(err));
console.error('[VideoCallNew] 错误详情 - errMsg:', err.errMsg, 'errCode:', err.errCode);
this.isSpeaking = false;
this.callStatus = '通话中';
this.isProcessing = false;
uni.showToast({
title: '音频播放失败',
icon: 'none',
duration: 2000
});
if (this.videoContext) {
this.videoContext.pause();
}
});
this.audioContext.onPlay(() => {
console.log('[VideoCallNew] ▶️ 音频播放事件触发');
this.audioPlayedInSession = true;
this.audioPlayStartAt = Date.now();
// 音频开始时可确保视频处于播放状态,但不要反复 seek(0)(容易导致系统打断/解码异常)
if (this.videoContext) {
this.videoContext.play();
}
});
this.audioContext.onPause(() => {
console.log('[VideoCallNew] ⏸️ 音频暂停事件触发');
// 如果AI正在说话时音频被暂停尝试恢复播放
if (this.isSpeaking) {
console.warn('[VideoCallNew] ⚠️ AI说话时音频被暂停尝试恢复播放...');
setTimeout(() => {
if (this.isSpeaking && this.audioContext) {
try {
this.audioContext.play();
console.log('[VideoCallNew] ✅ 音频已恢复播放');
} catch (e) {
console.error('[VideoCallNew] ❌ 恢复音频播放失败:', e);
}
}
}, 100);
}
});
this.audioContext.onWaiting(() => {
console.log('[VideoCallNew] ⏳ 音频加载中...');
});
this.audioContext.onStop(() => {
console.log('[VideoCallNew] 音频停止事件触发');
});
this.audioContext.onWaiting(() => {
console.log('[VideoCallNew] 音频等待加载');
});
this.audioContext.onCanplay(() => {
console.log('[VideoCallNew] 音频可以播放');
this.tryPlayAudioForSession('canplay2');
});
},
tryPlayAudioForSession(reason) {
const sessionId = this.audioPlayTargetSessionId;
if (!sessionId || sessionId !== this.audioSessionId) return;
if (!this.audioContext || !this.isSpeaking) return;
if (this.audioPlayInvokedInSession) return;
this.audioPlayInvokedInSession = true;
try {
this.audioContext.play();
console.log('[VideoCallNew] ✅ 已触发音频播放(', reason, ')');
} catch (e) {
console.error('[VideoCallNew] ❌ 触发音频播放失败(', reason, '):', e);
}
},
recreateAudioContext() {
return new Promise((resolve) => {
try {
if (this.audioContext) {
try {
this.audioContext.stop();
} catch (e) {
// ignore
}
try {
this.audioContext.destroy();
} catch (e) {
// ignore
}
}
} catch (e) {
// ignore
}
this.audioContext = null;
this.initAudioContext();
setTimeout(() => resolve(), 50);
});
},
diagnoseAudioUrl(audioUrl) {
if (!audioUrl) return;
console.log('[VideoCallNew] 🔍 音频诊断开始下载以检测是否为有效mp3', audioUrl);
try {
uni.request({
url: audioUrl,
method: 'HEAD',
timeout: 20000,
success: (res) => {
console.log('[VideoCallNew] 🔍 音频诊断HEAD statusCode=', res.statusCode);
console.log('[VideoCallNew] 🔍 音频诊断HEAD headers=', res.header || {});
},
fail: (e) => {
console.log('[VideoCallNew] 🔍 音频诊断HEAD失败', e);
}
});
} catch (e) {
// ignore
}
uni.downloadFile({
url: audioUrl,
timeout: 20000,
success: (res) => {
console.log('[VideoCallNew] 🔍 音频诊断downloadFile statusCode=', res.statusCode);
if (res.statusCode !== 200 || !res.tempFilePath) return;
try {
const fsm = uni.getFileSystemManager();
try {
fsm.readFile({
filePath: res.tempFilePath,
position: 0,
length: 16,
success: (readRes) => {
let hex = '';
try {
const u8 = new Uint8Array(readRes.data);
hex = Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(' ');
} catch (e) {
hex = '';
}
console.log('[VideoCallNew] 🔍 音频诊断file header(16 bytes)=', hex);
},
fail: (e) => {
console.log('[VideoCallNew] 🔍 音频诊断readFile失败', e);
}
});
} catch (e) {
// ignore
}
fsm.getFileInfo({
filePath: res.tempFilePath,
success: (info) => {
console.log('[VideoCallNew] 🔍 音频诊断tempFile size=', info.size, 'path=', res.tempFilePath);
},
fail: (e) => {
console.log('[VideoCallNew] 🔍 音频诊断getFileInfo失败', e);
}
});
} catch (e) {
console.log('[VideoCallNew] 🔍 音频诊断异常:', e);
}
},
fail: (e) => {
console.log('[VideoCallNew] 🔍 音频诊断downloadFile失败', e);
}
});
},
generateAudioCacheKey(url) {
if (!url) return '';
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'audiocache_' + Math.abs(hash);
},
getPlayableAudioSrc(audioUrl) {
return new Promise((resolve) => {
if (!audioUrl) {
resolve('');
return;
}
const cacheKey = this.generateAudioCacheKey(audioUrl);
this.audioCacheKey = cacheKey;
const cachedPath = uni.getStorageSync(cacheKey);
if (cachedPath) {
uni.getSavedFileInfo({
filePath: cachedPath,
success: () => {
resolve(cachedPath);
},
fail: () => {
try { uni.removeStorageSync(cacheKey); } catch (e) {}
resolve('');
}
});
return;
}
uni.downloadFile({
url: audioUrl,
timeout: 60000,
success: (res) => {
if (res.statusCode !== 200) {
resolve('');
return;
}
uni.saveFile({
tempFilePath: res.tempFilePath,
success: (saveRes) => {
try { uni.setStorageSync(cacheKey, saveRes.savedFilePath); } catch (e) {}
resolve(saveRes.savedFilePath);
},
fail: () => {
resolve(res.tempFilePath);
}
});
},
fail: () => {
resolve('');
}
});
});
},
clearAudioCacheForUrl(audioUrl) {
return new Promise((resolve) => {
try {
const cacheKey = this.generateAudioCacheKey(audioUrl);
const cachedPath = uni.getStorageSync(cacheKey);
try { uni.removeStorageSync(cacheKey); } catch (e) {}
if (cachedPath) {
uni.removeSavedFile({
filePath: cachedPath,
complete: () => {
resolve();
}
});
return;
}
} catch (e) {
// ignore
}
resolve();
});
},
downloadAudioTemp(audioUrl) {
return new Promise((resolve) => {
if (!audioUrl) {
resolve('');
return;
}
uni.downloadFile({
url: audioUrl,
timeout: 20000,
success: (res) => {
if (res.statusCode === 200 && res.tempFilePath) {
resolve(res.tempFilePath);
return;
}
resolve('');
},
fail: () => {
resolve('');
}
});
});
},
// 触摸开始(长按录音)
handleTouchStart(e) {
console.log('[VideoCallNew] ========== 触摸开始 ==========');
console.log('[VideoCallNew] 事件对象:', e);
console.log('[VideoCallNew] isProcessing:', this.isProcessing, 'isRecording:', this.isRecording);
if (this.isProcessing || this.isRecording) {
console.log('[VideoCallNew] 跳过:正在处理或录音中');
return;
}
this.isTouching = true;
// 设置长按定时器300ms后开始录音
this.longPressTimer = setTimeout(() => {
if (this.isTouching) {
console.log('[VideoCallNew] 长按触发,开始录音');
this.startRecording(true);
}
}, 300);
},
// 触摸结束(松开发送)
handleTouchEnd(e) {
console.log('[VideoCallNew] ========== 触摸结束 ==========');
console.log('[VideoCallNew] 事件对象:', e);
this.isTouching = false;
// 清除长按定时器
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
// 如果正在录音,则停止并发送
if (this.isRecording) {
console.log('[VideoCallNew] 松开手指,发送录音');
this.stopRecordingInternal('manual');
}
},
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] : '';
},
// 触摸取消(意外中断)
handleTouchCancel(e) {
console.log('[VideoCallNew] ========== 触摸取消 ==========');
console.log('[VideoCallNew] 事件对象:', e);
this.isTouching = false;
// 清除长按定时器
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
// 如果正在录音,则取消录音
if (this.isRecording) {
console.log('[VideoCallNew] 触摸取消,停止录音');
this.isRecording = false;
this.recorderManager.stop();
this.resetVADState();
uni.showToast({
title: '已取消录音',
icon: 'none',
duration: 1500
});
}
},
handleGlobalTouchEnd(e) {
console.log('[VideoCallNew] ========== 全局触摸结束兜底 ==========', e);
this.isTouching = false;
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (this.isRecording) {
this.stopRecordingInternal('manual');
}
},
handleGlobalTouchCancel(e) {
console.log('[VideoCallNew] ========== 全局触摸取消兜底 ==========', e);
this.isTouching = false;
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (this.isRecording) {
this.isRecording = false;
this.recorderManager.stop();
this.resetVADState();
uni.showToast({
title: '已取消录音',
icon: 'none',
duration: 1500
});
}
},
// 开始录音
startRecording(isManual = false) {
if (this.isProcessing) return;
// AI正在说话时禁止自动录音但允许用户手动点击触发
if (this.isSpeaking && !isManual) {
return;
}
this.resetVADState();
this.recordingStartTime = Date.now();
this.vadLastSoundTime = this.recordingStartTime;
this.vadLastFrameTime = this.recordingStartTime;
this.isRecording = true;
console.log('[VAD] 开始录音,时间:', new Date().toLocaleTimeString());
const enableVadAutoStop = this.vadEnabled && !isManual;
if (enableVadAutoStop) {
this.vadMaxTimer = setTimeout(() => {
console.log('[VAD] 达到最大录音时长15秒强制停止');
this.stopRecordingInternal('max_duration');
}, this.vadMaxDurationMs); // 最大录音时长从6秒增加到15秒
this.vadWatchTimer = setInterval(() => {
if (!this.isRecording) return;
const now = Date.now();
const elapsed = now - (this.recordingStartTime || now);
const silenceDuration = now - (this.vadLastSoundTime || now);
// 无帧回调兜底
if (now - (this.vadLastFrameTime || now) > 8000) {
console.log('[VAD] 无帧回调超时,停止录音');
this.stopRecordingInternal('no_frame');
return;
}
// 一直未检测到说话,提前结束,避免空录
if (!this.vadSpeaking && elapsed > 2500) {
console.log('[VAD] 未检测到语音,自动停止录音');
this.stopRecordingInternal('no_speech');
return;
}
// 静音超时需已检测到说话且录音时长超过500ms
if (elapsed > 500 && this.vadSpeaking && silenceDuration > this.vadSilenceMs) {
console.log('[VAD] 检测到静音超时');
console.log('[VAD] 录音总时长:', elapsed, 'ms, 静音时长:', silenceDuration, 'ms');
this.stopRecordingInternal('silence');
return;
}
}, 150); // 检测间隔优化到150ms更快响应
}
const recorderOptions = {
format: 'mp3',
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 32000
};
if (isManual) {
recorderOptions.duration = 600000;
}
// 只有当VAD启用且onFrameRecorded支持时才设置frameSize
// 注意frameSize单位是毫秒ms微信小程序/App只支持200或500
if (this.vadEnabled && this.recorderManager.onFrameRecorded) {
recorderOptions.frameSize = 200; // 200ms触发一次帧回调快速响应
console.log('[VAD] 启用帧回调frameSize: 200ms');
}
console.log('[VAD] 录音参数:', JSON.stringify(recorderOptions));
this.recorderManager.start(recorderOptions);
this.callStatus = '正在录音(自动识别说话)';
},
// 停止录音
stopRecording() {
if (!this.isRecording) return;
this.stopRecordingInternal('manual');
},
// VAD/手动共用停止逻辑
stopRecordingInternal(reason = 'manual') {
if (!this.isRecording) return;
this.isRecording = false;
this.recorderManager.stop();
this.stopFallbackActive = true;
this.stopFallbackRetries = 0;
if (this.stopFallbackTimer) {
clearTimeout(this.stopFallbackTimer);
this.stopFallbackTimer = null;
}
this.stopFallbackTimer = setTimeout(() => {
this.retryStopRecording();
}, 1200);
this.isProcessing = true;
this.processingText = '正在聆听...';
this.callStatus = '处理中...';
this.recognizingText = '';
this.resetVADState();
console.log('[VideoCallNew] 停止录音,原因:', reason);
// 用户交互时激活音频上下文(解决自动播放限制)
if (!this.audioContextActivated) {
// iOS上需要先播放一次音频才能解锁
try {
const tempAudio = uni.createInnerAudioContext();
tempAudio.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
tempAudio.volume = 0;
// #ifndef MP-WEIXIN
tempAudio.play();
// #endif
setTimeout(() => {
tempAudio.destroy();
}, 100);
this.audioContextActivated = true;
console.log('[VideoCallNew] 音频上下文已激活');
} catch (e) {
console.warn('[VideoCallNew] 激活音频上下文失败:', e);
}
}
// 设置30秒超时保护
this.processingTimeout = setTimeout(() => {
if (this.isProcessing) {
console.warn('[VideoCallNew] 处理超时,重置状态');
this.isProcessing = false;
this.callStatus = '通话中';
uni.showToast({
title: '处理超时,请重试',
icon: 'none'
});
}
}, 30000);
},
retryStopRecording() {
if (!this.stopFallbackActive) return;
if (!this.recorderManager) return;
this.stopFallbackRetries += 1;
if (this.stopFallbackRetries > 2) {
this.stopFallbackActive = false;
this.stopFallbackTimer = null;
this.isProcessing = false;
this.callStatus = '通话中';
return;
}
try {
this.recorderManager.stop();
} catch (e) {
}
this.stopFallbackTimer = setTimeout(() => {
this.retryStopRecording();
}, 1200);
},
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;
}
},
handleVADFrame(frameBuffer) {
if (!frameBuffer || frameBuffer.byteLength === 0) return;
this.vadLastFrameTime = Date.now();
const volume = this.calculateRms(frameBuffer);
// 检测到语音
if (volume > this.vadVoiceThreshold) {
if (!this.vadSpeaking) {
console.log('[VAD] 检测到语音开始,音量:', volume.toFixed(4));
}
this.vadSpeaking = true;
this.vadSilenceStart = null;
this.vadLastSoundTime = Date.now();
return;
}
// 静音判定(只在已检测到说话后才开始计时)
if (this.vadSpeaking) {
if (!this.vadSilenceStart) {
this.vadSilenceStart = Date.now();
console.log('[VAD] 检测到静音开始,音量:', volume.toFixed(4));
}
} else {
// 还没检测到说话,只记录音量
if (Math.random() < 0.1) { // 10%概率打印,避免刷屏
console.log('[VAD] 等待语音中,当前音量:', volume.toFixed(4), '阈值:', this.vadVoiceThreshold);
}
}
},
calculateRms(frameBuffer) {
const dataView = new DataView(frameBuffer);
const len = dataView.byteLength / 2;
if (len === 0) return 0;
let sum = 0;
for (let i = 0; i < len; i++) {
const sample = dataView.getInt16(i * 2, true);
sum += sample * sample;
}
return Math.sqrt(sum / len) / 32768;
},
startAutoLoop() {
if (!this.autoListen) return;
if (this.autoLoopTimer) {
clearInterval(this.autoLoopTimer);
}
this.autoLoopTimer = setInterval(() => {
// 只有在通话真正开始后才自动录音且避免打断AI播放
if (!this.callStarted) return;
if (!this.autoListen) return;
if (this.isSpeaking) return;
if (!this.isRecording && !this.isProcessing) {
this.startRecording(false);
}
}, 2000);
},
// 处理对话
async processConversation() {
try {
this.processingText = '正在识别...';
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
// 添加进度提示,让用户感觉更快
setTimeout(() => {
if (this.isProcessing) {
this.processingText = '正在思考...';
}
}, 800);
setTimeout(() => {
if (this.isProcessing) {
this.processingText = '正在回复...';
}
}, 2000);
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.selectedVoiceId;
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,
serviceType: 'VIDEO_CALL',
systemPrompt: this.systemPrompt || '',
saveHistory: 'false'
};
if (this.selectedDialect) {
data.dialect = this.selectedDialect;
}
if (this.selectedLanguageHint) {
data.languageHints = this.selectedLanguageHint;
}
return data;
})(),
success: (res) => {
// 清除超时定时器
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
this.processingTimeout = null;
}
console.log('[VideoCallNew] 对话响应:', res);
if (res.statusCode === 200) {
const result = JSON.parse(res.data);
if (result.success) {
// 添加用户消息
this.addMessage('user', result.recognizedText);
// 添加AI回复
this.addMessage('ai', result.aiResponse);
// 播放AI回复
this.playAIResponse(result.audioFile);
} else {
// 识别失败,重置状态
const msg = result.message || '未识别到语音内容';
console.error('[VideoCallNew] 识别失败:', msg);
uni.showModal({
title: '对话失败',
content: msg + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
showCancel: false,
confirmText: '我知道了'
});
this.isProcessing = false;
this.callStatus = '通话中';
}
} else {
// 请求失败,重置状态
console.error('[VideoCallNew] 请求失败,状态码:', res.statusCode);
let parsed = null;
try {
parsed = JSON.parse(res.data);
} catch (e) {
parsed = null;
}
const msg = (parsed && parsed.message) ? parsed.message : ('对话请求失败: HTTP ' + res.statusCode);
uni.showModal({
title: '对话失败',
content: msg + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
showCancel: false,
confirmText: '我知道了'
});
this.isProcessing = false;
this.callStatus = '通话中';
}
},
fail: (err) => {
// 清除超时定时器
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
this.processingTimeout = null;
}
console.error('[VideoCallNew] 对话失败:', err);
uni.showModal({
title: '对话失败',
content: '网络请求失败:' + (err.errMsg || '未知错误') + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
showCancel: false,
confirmText: '我知道了'
});
this.isProcessing = false;
this.callStatus = '通话中';
}
});
} catch (error) {
// 清除超时定时器
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
this.processingTimeout = null;
}
console.error('[VideoCallNew] 对话失败:', error);
uni.showModal({
title: '对话失败',
content: (error.message || '未知错误') + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
showCancel: false,
confirmText: '我知道了'
});
this.isProcessing = false;
this.callStatus = '通话中';
}
},
// 播放AI回复
async playAIResponse(audioFile) {
if (!audioFile) {
console.error('[VideoCallNew] ❌ 音频文件为空');
uni.showToast({
title: '音频文件缺失',
icon: 'none'
});
this.isProcessing = false;
this.callStatus = '通话中';
return;
}
this.processingText = '正在回复...';
const audioUrl = `${this.API_BASE}/api/conversation/audio/${audioFile}`;
console.log('[VideoCallNew] 🎵 准备播放音频:', audioUrl);
console.log('[VideoCallNew] 📁 音频文件名:', audioFile);
// 复用单一音频实例,避免反复重建导致卡顿
if (!this.audioContext) {
console.log('[VideoCallNew] 初始化音频上下文...');
this.initAudioContext();
} else {
try {
console.log('[VideoCallNew] 停止之前的音频...');
this.audioContext.stop();
} catch (e) {
console.warn('[VideoCallNew] 停止音频实例失败:', e);
}
}
this.audioSessionId += 1;
const sessionId = this.audioSessionId;
this.audioPlayedInSession = false;
this.audioPlayStartAt = 0;
this.audioTriedRedownload = false;
this.audioTriedRecreateContext = false;
this.audioPlayInvokedInSession = false;
this.audioPlayTargetSessionId = sessionId;
this.audioDiagnosedInSession = false;
this.pendingAudioUrl = audioUrl;
this.audioTriedFallback = false;
let playableSrc = audioUrl;
try {
const tempSrc = await this.downloadAudioTemp(audioUrl);
if (sessionId === this.audioSessionId && tempSrc) {
playableSrc = tempSrc;
}
} catch (e) {
// ignore
}
if (sessionId !== this.audioSessionId || !this.audioContext) return;
this.currentAudioSrc = playableSrc;
this.audioContext.src = playableSrc;
// 确保音量设置为最大
this.audioContext.volume = 1.0;
// 设置不遵循静音开关
try {
this.audioContext.obeyMuteSwitch = false;
} catch (e) {
console.warn('[VideoCallNew] 设置obeyMuteSwitch失败:', e);
}
console.log('[VideoCallNew] 🔊 音频源已设置:', this.audioContext.src);
console.log('[VideoCallNew] 🔊 音频音量:', this.audioContext.volume);
console.log('[VideoCallNew] 📢 如果声音过小请检查1.手机系统音量 2.媒体音量 3.静音开关');
// 立即开始播放视频从第1秒开始避免首帧静止感
console.log('[VideoCallNew] 🎬 AI开始回复从第1秒开始循环播放视频');
this.isSpeaking = true;
this.callStatus = '对方正在说话...';
if (this.videoContext) {
this.videoContext.seek(1);
setTimeout(() => {
if (this.videoContext) {
this.videoContext.play();
}
}, 100);
}
console.log('[VideoCallNew] ▶️ 开始播放音频...');
// canplay 触发播放为主,兜底:延迟尝试一次(只会触发一次)
setTimeout(() => {
if (sessionId !== this.audioSessionId) return;
this.tryPlayAudioForSession('timer');
}, 500);
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;
});
},
// 清空历史
handleClearHistory(e) {
console.log('[VideoCallNew] ========== 清空按钮被点击 ==========');
console.log('[VideoCallNew] 事件对象:', e);
// 立即显示反馈
uni.showToast({
title: '清空按钮已点击',
icon: 'none',
duration: 1000
});
uni.showModal({
title: '确认清空',
content: '确定要清空对话历史吗?',
confirmColor: '#8B7355',
success: (res) => {
if (res.confirm) {
this.clearHistory();
}
}
});
},
clearHistory() {
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
uni.request({
url: `${this.API_BASE}/api/conversation/clear-history`,
method: 'POST',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.data && res.data.success) {
this.messages = [];
uni.showToast({
title: '已清空',
icon: 'success'
});
} else {
uni.showToast({
title: res.data?.message || '清空失败',
icon: 'none'
});
}
},
fail: (err) => {
console.error('[VideoCallNew] 清空历史失败:', err);
uni.showToast({
title: '清空失败,请稍后重试',
icon: 'none'
});
}
});
},
// 自动挂断通话10分钟到期
autoEndCall() {
// 清除定时器
if (this.durationTimer) {
clearInterval(this.durationTimer);
this.durationTimer = null;
}
// 震动提醒
// 清理资源
this.cleanup();
// 显示提示弹窗
uni.showModal({
title: '通话已结束',
content: '您本次通话已达10分钟时长上限通话已自动结束。',
showCancel: false,
confirmText: '好的',
confirmColor: '#8B7355',
success: (res) => {
if (res.confirm) {
// 返回首页
uni.reLaunch({
url: '/pages/index/index'
});
}
}
});
},
// 手动挂断通话
endCall(e) {
console.log('[VideoCallNew] ========== 挂断按钮被点击 ==========');
console.log('[VideoCallNew] 事件对象:', e);
// 立即显示反馈
uni.showToast({
title: '挂断按钮已点击',
icon: 'none',
duration: 1000
});
uni.showModal({
title: '结束通话',
content: `通话时长:${this.callDuration}\n确定要结束通话吗`,
confirmColor: '#eb3349',
success: (res) => {
if (res.confirm) {
uni.showLoading({
title: '正在挂断...',
mask: true
});
setTimeout(() => {
uni.hideLoading();
this.cleanup();
uni.showToast({
title: '通话已结束',
icon: 'success',
duration: 1500
});
// 延迟后跳转到首页
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
});
}, 1500);
}, 800);
}
}
});
},
// 返回
goBack() {
if (this.callStarted) {
this.endCall();
} else {
uni.navigateBack();
}
},
// 跳转到复活照片
goToRevival() {
console.log('[VideoCallNew] 跳转到复活照片页面');
uni.navigateTo({
url: '/pages/revival/revival-original',
fail: (err) => {
console.error('[VideoCallNew] 跳转失败:', err);
}
});
},
// 视频元数据加载完成(更早触发)
onVideoMetadataLoaded() {
console.log('[VideoCallNew] 视频元数据加载完成,可以开始播放');
// 元数据加载完成后立即隐藏接通中状态
if (this.isConnecting) {
this.connectingText = '已接通';
// 快速隐藏接通界面,提升用户体验
setTimeout(() => {
this.isConnecting = false;
uni.showToast({
title: '通话已接通',
icon: 'success',
duration: 1500
});
}, 300);
}
},
// 视频加载成功
onVideoLoaded() {
console.log('[VideoCallNew] 视频数据加载成功');
console.log('[VideoCallNew] 当前视频URL:', this.selectedVideoUrl);
// #ifndef MP-WEIXIN
// 非微信小程序:可做短暂自动播放避免黑屏
if (this.videoContext && !this.isSpeaking && !this.videoInitialPlayed) {
console.log('[VideoCallNew] 视频数据加载完成,自动播放一秒避免黑屏');
this.videoInitialPlayed = true;
this.videoContext.seek(0);
this.videoContext.play();
// 播放一秒后暂停
setTimeout(() => {
if (this.videoContext && !this.isSpeaking) {
this.videoContext.pause();
console.log('[VideoCallNew] 视频已播放一秒暂停等待AI回复');
}
}, 1000);
}
// #endif
},
// 视频预加载完成
onVideoPreloaded(videoId) {
console.log('[VideoCallNew] 视频预加载完成:', videoId);
this.videoPreloadStatus[videoId] = true;
this.$forceUpdate();
},
// 视频播放事件
onVideoPlay() {
console.log('[VideoCallNew] 视频开始播放');
},
// 视频暂停事件
onVideoPause() {
console.log('[VideoCallNew] 视频已暂停');
},
// 视频时间更新(当前不做额外处理,保留占位)
onVideoTimeUpdate(e) {
// console.log('[VideoCallNew] 视频播放进度:', e.detail);
},
// 视频加载错误
onVideoError(e) {
console.error('[VideoCallNew] 视频加载失败:', e);
console.error('[VideoCallNew] 视频URL:', this.selectedVideoUrl);
console.error('[VideoCallNew] 错误详情:', JSON.stringify(e));
uni.showToast({
title: '视频加载失败',
icon: 'none',
duration: 3000
});
},
// 加载视频缓存信息
async loadVideoCache() {
try {
const cache = uni.getStorageSync('videoCache');
console.log('[VideoCallNew] 💾 读取缓存数据:', cache);
if (cache) {
this.videoCache = JSON.parse(cache);
console.log('[VideoCallNew] ✅ 加载视频缓存信息:', Object.keys(this.videoCache).length, '个视频');
console.log('[VideoCallNew] 缓存详情:', this.videoCache);
} else {
console.log('[VideoCallNew] ⚠️ 未找到缓存数据');
this.videoCache = {};
}
} catch (e) {
console.error('[VideoCallNew] ❌ 加载视频缓存失败:', e);
this.videoCache = {};
}
},
// 保存视频缓存信息
async saveVideoCache() {
try {
const cacheStr = JSON.stringify(this.videoCache);
uni.setStorageSync('videoCache', cacheStr);
console.log('[VideoCallNew] 💾 保存视频缓存信息成功');
console.log('[VideoCallNew] 保存内容:', cacheStr);
// 验证保存是否成功
const verify = uni.getStorageSync('videoCache');
if (verify) {
console.log('[VideoCallNew] ✅ 验证保存成功');
} else {
console.error('[VideoCallNew] ❌ 验证保存失败,数据未持久化');
}
} catch (e) {
console.error('[VideoCallNew] ❌ 保存视频缓存失败:', e);
}
},
// 检查并加载本地视频(启动时调用)
async checkAndLoadLocalVideo(videoId, videoUrl) {
if (!videoId || !videoUrl) {
console.warn('[VideoCallNew] ⚠️ 视频ID或URL为空');
return;
}
console.log('[VideoCallNew] 🔍 开始检查视频缓存');
console.log('[VideoCallNew] 视频ID:', videoId);
console.log('[VideoCallNew] 当前缓存对象:', this.videoCache);
console.log('[VideoCallNew] 缓存中是否有该ID:', videoId in this.videoCache);
// 检查是否已缓存
if (this.videoCache[videoId]) {
const cachedPath = this.videoCache[videoId];
console.log('[VideoCallNew] 📝 发现缓存记录,验证文件:', cachedPath);
// 验证文件是否存在
const fileExists = await this.checkFileExists(cachedPath);
console.log('[VideoCallNew] 文件存在性检查结果:', fileExists);
if (fileExists) {
this.localVideoUrl = cachedPath;
console.log('[VideoCallNew] ✅ 本地视频文件存在,使用缓存');
console.log('[VideoCallNew] 已设置 localVideoUrl:', this.localVideoUrl);
return;
} else {
console.warn('[VideoCallNew] ⚠️ 缓存记录存在但文件丢失,清除缓存');
delete this.videoCache[videoId];
await this.saveVideoCache();
}
} else {
console.log('[VideoCallNew] ❌ 缓存中未找到视频ID:', videoId);
}
// 无缓存时先使用原始URL播放同时在后台下载缓存
console.log('[VideoCallNew] <20> 未找到缓存先使用原始URL播放同时后台下载');
this.localVideoUrl = ''; // 清空localVideoUrl让video组件使用selectedVideoUrl
// 启动后台下载(不阻塞播放)
this.startBackgroundDownload(videoUrl, videoId);
},
// 批量预加载所有视频
async preloadAllVideos() {
if (this.preCachingInProgress) {
console.log('[VideoCallNew] 预缓存已在进行中,跳过');
return;
}
this.preCachingInProgress = true;
console.log('[VideoCallNew] 🚀 开始预缓存所有视频');
// 按优先级排序:最新的视频优先下载
const sortedVideos = [...this.videos].sort((a, b) => {
const timeA = a.created_at || a.createdAt || 0;
const timeB = b.created_at || b.createdAt || 0;
return timeB - timeA; // 降序,最新的在前
});
let cachedCount = 0;
let downloadCount = 0;
for (const video of sortedVideos) {
const videoId = video.id;
const videoUrl = video.edited_video_url || video.videoUrl || video.local_video_path || video.video_url || video.localVideoPath;
if (!videoId || !videoUrl) {
console.warn('[VideoCallNew] 视频缺少ID或URL跳过:', video.name);
continue;
}
// 检查是否已缓存
if (this.videoCache[videoId]) {
const cachedPath = this.videoCache[videoId];
const fileExists = await this.checkFileExists(cachedPath);
if (fileExists) {
cachedCount++;
this.cacheProgress[videoId] = { progress: 100, status: 'completed' };
console.log(`[VideoCallNew] ✅ 视频已缓存: ${video.name || videoId}`);
continue;
} else {
// 缓存记录存在但文件丢失
delete this.videoCache[videoId];
await this.saveVideoCache();
}
}
// 开始下载
downloadCount++;
console.log(`[VideoCallNew] 📥 开始预缓存 [${downloadCount}/${sortedVideos.length - cachedCount}]: ${video.name || videoId}`);
// 初始化进度
this.cacheProgress[videoId] = { progress: 0, status: 'downloading' };
// 异步下载,不等待完成
this.startBackgroundDownload(videoUrl, videoId, video.name).catch(err => {
console.error(`[VideoCallNew] 预缓存失败: ${video.name}`, err);
this.cacheProgress[videoId] = { progress: 0, status: 'failed' };
});
// 添加小延迟避免同时发起太多请求
await new Promise(resolve => setTimeout(resolve, 200));
}
console.log(`[VideoCallNew] 🎉 预缓存启动完成: 已缓存 ${cachedCount} 个,正在下载 ${downloadCount}`);
this.preCachingInProgress = false;
},
// 后台下载视频(不阻塞主流程)
async startBackgroundDownload(videoUrl, videoId, videoName = '') {
if (!videoUrl || !videoId) {
return;
}
// 检查是否正在下载
if (this.downloadingVideos[videoId]) {
console.log('[VideoCallNew] 视频已在下载队列中');
return;
}
console.log(`[VideoCallNew] 📥 开始后台下载视频: ${videoName || videoId}`, videoUrl);
const downloadPromise = new Promise((resolve, reject) => {
const downloadTask = uni.downloadFile({
url: videoUrl,
success: (res) => {
if (res.statusCode === 200) {
console.log('[VideoCallNew] 视频下载成功,临时路径:', res.tempFilePath);
// 使用uni.saveFile保存到本地永久存储
uni.saveFile({
tempFilePath: res.tempFilePath,
success: (saveRes) => {
console.log(`[VideoCallNew] ✅ 视频保存成功 ${videoName || videoId}:`, saveRes.savedFilePath);
// 更新缓存
this.videoCache[videoId] = saveRes.savedFilePath;
// 如果是当前选中的视频更新localVideoUrl
if (this.selectedVideoId === videoId) {
this.localVideoUrl = saveRes.savedFilePath;
console.log('[VideoCallNew] 🎯 当前视频缓存完成,已更新播放源');
// 给用户一个轻量级提示(仅在通话中时显示)
if (this.callStarted) {
uni.showToast({
title: '已切换到本地播放',
icon: 'success',
duration: 1500
});
}
}
this.saveVideoCache();
// 更新进度状态
if (this.cacheProgress[videoId]) {
this.cacheProgress[videoId] = { progress: 100, status: 'completed' };
}
console.log('[VideoCallNew] 💾 缓存已更新,下次启动将直接使用');
delete this.downloadingVideos[videoId];
resolve(saveRes.savedFilePath);
},
fail: (err) => {
console.error(`[VideoCallNew] 视频保存失败 ${videoName || videoId}:`, err);
// 使用临时文件
if (this.selectedVideoId === videoId) {
this.localVideoUrl = res.tempFilePath;
}
// 标记为失败
if (this.cacheProgress[videoId]) {
this.cacheProgress[videoId] = { progress: 100, status: 'failed' };
}
delete this.downloadingVideos[videoId];
resolve(res.tempFilePath);
}
});
} else {
console.error(`[VideoCallNew] 视频下载失败 ${videoName || videoId},状态码:`, res.statusCode);
// 标记为失败
if (this.cacheProgress[videoId]) {
this.cacheProgress[videoId] = { progress: 0, status: 'failed' };
}
delete this.downloadingVideos[videoId];
reject(new Error('下载失败'));
}
},
fail: (err) => {
console.error(`[VideoCallNew] 视频下载失败 ${videoName || videoId}:`, err);
// 标记为失败
if (this.cacheProgress[videoId]) {
this.cacheProgress[videoId] = { progress: 0, status: 'failed' };
}
delete this.downloadingVideos[videoId];
reject(err);
}
});
// 记录下载进度
downloadTask.onProgressUpdate((res) => {
const progress = res.progress || 0;
if (this.cacheProgress[videoId]) {
this.cacheProgress[videoId].progress = progress;
}
// 每20%打印一次日志
if (progress % 20 === 0) {
console.log(`[VideoCallNew] 下载进度 ${videoName || videoId}: ${progress}%`);
}
});
});
this.downloadingVideos[videoId] = downloadPromise;
try {
await downloadPromise;
} catch (e) {
console.error('[VideoCallNew] 下载视频异常:', e);
}
},
// 检查文件是否存在
async checkFileExists(filePath) {
return new Promise((resolve) => {
try {
// 检查是否支持文件系统API
if (typeof uni.getFileSystemManager !== 'function') {
console.log('[VideoCallNew] 💡 当前平台不支持FileSystemManager信任缓存记录');
resolve(true);
return;
}
const fs = uni.getFileSystemManager();
fs.access({
path: filePath,
success: () => {
console.log('[VideoCallNew] ✅ 文件存在验证通过');
resolve(true);
},
fail: () => {
console.warn('[VideoCallNew] ❌ 文件不存在');
resolve(false);
}
});
} catch (e) {
console.warn('[VideoCallNew] ⚠️ 检查文件存在性异常:', e);
// 出错时信任缓存记录,避免误删缓存
console.log('[VideoCallNew] 💡 异常情况下信任缓存记录');
resolve(true);
}
});
},
// 清理资源
cleanup() {
console.log('[VideoCallNew] 开始清理资源');
if (this.durationTimer) {
clearInterval(this.durationTimer);
this.durationTimer = null;
}
if (this.processingTimeout) {
clearTimeout(this.processingTimeout);
this.processingTimeout = null;
}
if (this.stopFallbackTimer) {
clearTimeout(this.stopFallbackTimer);
this.stopFallbackTimer = null;
}
this.stopFallbackActive = false;
this.stopFallbackRetries = 0;
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
if (this.audioContext) {
try {
this.audioContext.stop();
this.audioContext.destroy();
} catch (e) {
console.warn('[VideoCallNew] 清理音频上下文失败:', e);
}
this.audioContext = null;
}
if (this.recorderManager) {
try {
if (this.isRecording) {
this.recorderManager.stop();
}
} catch (e) {
console.warn('[VideoCallNew] 清理录音管理器失败:', e);
}
this.recorderManager = null;
}
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;
}
console.log('[VideoCallNew] 资源清理完成');
},
// 格式化时间
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
},
// 支付相关方法
handlePaymentClose() {
this.paymentModalData.show = false;
if (this._paymentReject) {
this._paymentReject(new Error('用户取消支付'));
}
},
async handlePaymentConfirm(paymentData) {
await processPayment(this, paymentData);
}
}
};
</script>
<style lang="scss" scoped>
.video-call-container {
min-height: 100vh;
background: #FDF8F2;
background-image:
radial-gradient(circle at 10% 20%, rgba(212, 185, 150, 0.1) 0%, transparent 20%),
radial-gradient(circle at 90% 80%, rgba(109, 139, 139, 0.1) 0%, transparent 20%);
display: flex;
flex-direction: column;
}
/* 顶部导航 */
.header {
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
padding: 30upx;
padding-top: calc(30upx + constant(safe-area-inset-top));
padding-top: calc(30upx + env(safe-area-inset-top));
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 8upx 30upx rgba(0, 0, 0, 0.16);
}
.back-btn {
font-size: 28upx;
color: white;
font-weight: 600;
padding: 10upx;
}
.header-title {
font-size: 32upx;
font-weight: bold;
color: white;
}
.placeholder {
width: 100upx;
}
/* 选择视频阶段 */
.select-video-section {
flex: 1;
display: flex;
flex-direction: column;
}
/* 加载状态 */
.loading-box {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.loading-text {
font-size: 28rpx;
color: #999;
}
/* 视频列表 */
.video-list {
flex: 1;
padding: 0 32rpx;
}
.list-header {
padding: 40rpx 0 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.list-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.list-count {
font-size: 24rpx;
color: #999;
}
/* 视频项 */
.video-item {
background: white;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s;
}
.video-item:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
}
/* 封面 */
.item-cover {
width: 120rpx;
height: 160rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f5f5f5;
flex-shrink: 0;
margin-right: 24rpx;
}
.cover-img {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
}
.placeholder-icon {
font-size: 48rpx;
opacity: 0.5;
}
/* 预加载视频(隐藏) */
.preload-video {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
z-index: -1;
}
/* 信息 */
.item-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
margin-right: 16rpx;
}
.info-name-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.info-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
}
.cache-badge {
flex-shrink: 0;
}
.cache-badge-text {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 20rpx;
white-space: nowrap;
}
.cache-badge-text.completed {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
color: #2e7d32;
font-weight: 500;
}
.cache-badge-text.downloading {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
color: #e65100;
font-weight: 500;
}
.info-text {
font-size: 24rpx;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作按钮 */
.item-action {
flex-shrink: 0;
}
.call-icon {
width: 88rpx;
height: 88rpx;
background: linear-gradient(135deg, #07c160 0%, #06ae56 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(7, 193, 96, 0.3);
}
.icon-text {
font-size: 40rpx;
}
/* 空状态 */
.empty-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 32rpx;
}
.empty-text {
font-size: 32rpx;
color: #666;
margin-bottom: 16rpx;
}
.empty-hint {
font-size: 26rpx;
color: #999;
margin-bottom: 40rpx;
}
.go-revival-btn {
padding: 24rpx 48rpx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
border: none;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: bold;
box-shadow: 0 8rpx 16rpx rgba(139, 115, 85, 0.3);
}
/* 通话中阶段 */
.call-active-section {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 0;
overflow: hidden;
z-index: 1;
}
/* 视频容器 */
.video-fullscreen {
position: fixed;
top: 220rpx;
left: 40rpx;
right: 40rpx;
bottom: calc(320rpx + 40rpx + constant(safe-area-inset-bottom));
bottom: calc(320rpx + 40rpx + env(safe-area-inset-bottom));
border-radius: 32rpx;
overflow: hidden;
background: #000;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
z-index: 1;
}
.video-player {
width: 100%;
height: 100%;
background: #000;
border-radius: 32rpx;
object-fit: cover;
position: relative;
z-index: 1;
pointer-events: none;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
.placeholder-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
margin-top: 20rpx;
}
/* 顶部状态栏 */
.call-status-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 80rpx 40rpx 40rpx;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
z-index: 1000;
pointer-events: none;
}
.call-status-bar .listening-chip {
pointer-events: auto;
}
.status-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.call-name {
font-size: 32rpx;
font-weight: 600;
color: white;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
}
.call-time {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
font-family: 'Courier New', monospace;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
}
.listening-chip {
margin-top: 12rpx;
padding: 18rpx 28rpx;
background: linear-gradient(135deg, #FFB6C1 0%, #FFC0CB 50%, #FFD4E5 100%);
border-radius: 50rpx;
border: 2rpx solid rgba(255, 255, 255, 0.8);
box-shadow: 0 8rpx 24rpx rgba(255, 182, 193, 0.4), 0 0 20rpx rgba(255, 192, 203, 0.3);
display: inline-flex;
align-items: center;
gap: 12rpx;
transition: all 0.3s ease;
animation: listeningGlow 1.5s ease-in-out infinite, listeningPulse 2s ease-in-out infinite;
position: relative;
z-index: 1001;
pointer-events: auto;
opacity: 1;
visibility: visible;
}
.listening-chip.inline-chip {
margin-top: 0;
}
.listening-icon {
font-size: 32rpx;
animation: iconBounce 1s ease-in-out infinite;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.3));
}
.listening-text {
font-size: 28rpx;
color: white;
font-weight: 700;
letter-spacing: 1rpx;
text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.4), 0 0 10rpx rgba(255, 255, 255, 0.3);
}
/* 对话历史 */
.chat-section {
flex: 1;
background: white;
border-radius: 24rpx;
padding: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
min-height: 150rpx;
}
.chat-messages {
height: 100%;
}
.message-item {
display: flex;
margin-bottom: 20rpx;
animation: fadeIn 0.3s;
}
.message-item.user {
justify-content: flex-end;
}
.message-item.ai {
justify-content: flex-start;
}
.message-bubble {
max-width: 70%;
padding: 20rpx 24rpx;
border-radius: 16rpx;
}
.message-item.user .message-bubble {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-bottom-right-radius: 4rpx;
}
.message-item.ai .message-bubble {
background: #f0f0f0;
border-bottom-left-radius: 4rpx;
}
.message-text {
font-size: 28rpx;
line-height: 1.5;
color: white;
}
.message-item.ai .message-text {
color: #333;
}
.empty-chat {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-chat-text {
font-size: 26rpx;
color: #999;
}
/* 底部控制区 */
.call-controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 320rpx;
padding: 40rpx 60rpx calc(80rpx + constant(safe-area-inset-bottom));
padding: 40rpx 60rpx calc(80rpx + env(safe-area-inset-bottom));
background: linear-gradient(0deg, rgba(0, 0, 0, 0.95) 0%, rgba(0, 0, 0, 0.6) 60%, transparent 100%);
z-index: 1001;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
/* 录音提示 */
.recording-hint {
text-align: center;
margin-bottom: 30rpx;
}
.hint-box {
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 16rpx 32rpx;
border-radius: 50rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10rpx);
}
.hint-box.listening-pulse {
background: linear-gradient(135deg, rgba(7, 193, 96, 0.9) 0%, rgba(6, 174, 86, 0.9) 100%);
animation: listeningPulse 1.5s ease-in-out infinite;
}
.hint-box.processing-pulse {
background: linear-gradient(135deg, rgba(255, 193, 7, 0.9) 0%, rgba(255, 152, 0, 0.9) 100%);
animation: processingPulse 1.5s ease-in-out infinite;
}
.hint-icon {
font-size: 32rpx;
animation: iconBounce 1s ease-in-out infinite;
}
.hint-text {
font-size: 28rpx;
color: white;
font-weight: 600;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
/* 控制按钮组 */
.control-buttons {
display: flex;
align-items: flex-end;
justify-content: space-around;
gap: 40rpx;
position: relative;
z-index: 1002;
pointer-events: auto;
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
position: relative;
z-index: 1003;
pointer-events: auto;
}
.btn-circle {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.5), 0 0 0 4rpx rgba(255, 255, 255, 0.3);
position: relative;
z-index: 1004;
}
.btn-circle.large {
width: 130rpx;
height: 130rpx;
background: rgba(255, 255, 255, 0.95);
}
.btn-circle.recording {
background: rgba(235, 51, 73, 0.95);
animation: recordingPulse 1s ease-in-out infinite;
}
.btn-circle.processing {
background: rgba(200, 200, 200, 0.95);
}
/* 新的说话按钮设计 - 椭圆形状 */
.speak-button {
position: relative;
width: 240rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1005;
}
.speak-button-inner {
width: 240rpx;
height: 160rpx;
border-radius: 80rpx;
background: linear-gradient(135deg, #FF6B6B 0%, #FFE66D 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(255, 107, 107, 0.6),
0 4rpx 16rpx rgba(255, 230, 109, 0.5),
0 0 0 6rpx rgba(255, 255, 255, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1006;
}
.speak-button.recording .speak-button-inner {
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
box-shadow: 0 12rpx 48rpx rgba(78, 205, 196, 0.6),
0 6rpx 24rpx rgba(68, 160, 141, 0.5),
0 0 0 6rpx rgba(255, 255, 255, 0.3);
transform: scale(1.05);
width: 260rpx;
height: 180rpx;
}
.speak-button.processing .speak-button-inner {
background: linear-gradient(135deg, #a8a8a8 0%, #7a7a7a 100%);
box-shadow: 0 8rpx 32rpx rgba(168, 168, 168, 0.3);
}
/* 录音时的外圈动画 - 椭圆形状 */
.speak-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 240rpx;
height: 160rpx;
border-radius: 80rpx;
border: 4rpx solid rgba(78, 205, 196, 0.6);
animation: speakRingPulse 1.5s ease-out infinite;
z-index: 1;
}
.speak-ring-outer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 240rpx;
height: 160rpx;
border-radius: 80rpx;
border: 3rpx solid rgba(68, 160, 141, 0.5);
animation: speakRingPulse 1.5s ease-out infinite 0.3s;
z-index: 0;
}
@keyframes speakRingPulse {
0% {
width: 240rpx;
height: 160rpx;
opacity: 1;
}
100% {
width: 320rpx;
height: 220rpx;
opacity: 0;
}
}
.btn-circle.end-call {
background: rgba(235, 51, 73, 0.98);
box-shadow: 0 8rpx 24rpx rgba(235, 51, 73, 0.6), 0 0 0 4rpx rgba(255, 255, 255, 0.3);
}
.btn-circle:active {
transform: scale(0.95);
}
.btn-icon {
font-size: 40rpx;
}
.btn-icon.large {
font-size: 52rpx;
}
.btn-icon.pulse {
animation: pulse 1s infinite;
}
.btn-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.98);
font-weight: 600;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.6), 0 0 20rpx rgba(0, 0, 0, 0.4);
}
.btn-label-new {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
transition: all 0.3s;
margin-top: 8rpx;
}
.btn-label-new.active {
color: #4ECDC4;
text-shadow: 0 2rpx 8rpx rgba(78, 205, 196, 0.6);
transform: scale(1.05);
}
/* 图标样式 */
.icon-delete {
width: 40rpx;
height: 40rpx;
position: relative;
}
.icon-delete::before,
.icon-delete::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 32rpx;
height: 4rpx;
background: #333;
border-radius: 2rpx;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
}
.icon-delete::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.icon-delete::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.icon-mic {
width: 40rpx;
height: 52rpx;
border: 4rpx solid #333;
border-radius: 20rpx 20rpx 20rpx 20rpx;
position: relative;
}
.icon-mic::after {
content: '';
position: absolute;
bottom: -12rpx;
left: 50%;
transform: translateX(-50%);
width: 30rpx;
height: 12rpx;
border: 4rpx solid #333;
border-top: none;
border-radius: 0 0 15rpx 15rpx;
}
.icon-mic::before {
content: '';
position: absolute;
bottom: -20rpx;
left: 50%;
transform: translateX(-50%);
width: 3rpx;
height: 8rpx;
background: #333;
}
/* 新的麦克风图标 */
.icon-mic-new {
width: 48rpx;
height: 64rpx;
border: 5rpx solid white;
border-radius: 24rpx 24rpx 24rpx 24rpx;
position: relative;
animation: micFloat 2s ease-in-out infinite;
}
.icon-mic-new::after {
content: '';
position: absolute;
bottom: -16rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 16rpx;
border: 5rpx solid white;
border-top: none;
border-radius: 0 0 18rpx 18rpx;
}
.icon-mic-new::before {
content: '';
position: absolute;
bottom: -26rpx;
left: 50%;
transform: translateX(-50%);
width: 4rpx;
height: 10rpx;
background: white;
}
@keyframes micFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6rpx);
}
}
.icon-recording {
width: 48rpx;
height: 48rpx;
background: white;
border-radius: 50%;
position: relative;
}
.icon-recording::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 32rpx;
height: 32rpx;
background: #eb3349;
border-radius: 50%;
}
/* 录音时的波形动画 */
.icon-wave-container {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
height: 60rpx;
}
.wave-bar {
width: 6rpx;
background: white;
border-radius: 3rpx;
animation: waveAnimation 1s ease-in-out infinite;
}
@keyframes waveAnimation {
0%, 100% {
height: 20rpx;
}
50% {
height: 50rpx;
}
}
.icon-loading {
width: 48rpx;
height: 48rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #666;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.icon-loading-new {
width: 56rpx;
height: 56rpx;
border: 5rpx solid rgba(255, 255, 255, 0.2);
border-top-color: white;
border-right-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.icon-hangup {
width: 48rpx;
height: 20rpx;
border: 5rpx solid white;
border-radius: 12rpx;
position: relative;
transform: rotate(-135deg);
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.3));
}
.icon-hangup::before {
content: '';
position: absolute;
right: -8rpx;
top: -12rpx;
width: 16rpx;
height: 16rpx;
border: 5rpx solid white;
border-left: none;
border-bottom: none;
border-radius: 0 8rpx 0 0;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes listeningGlow {
0%, 100% {
box-shadow: 0 8rpx 24rpx rgba(255, 182, 193, 0.4), 0 0 20rpx rgba(255, 192, 203, 0.3);
border-color: rgba(255, 255, 255, 0.8);
}
50% {
box-shadow: 0 12rpx 36rpx rgba(255, 182, 193, 0.6), 0 0 40rpx rgba(255, 192, 203, 0.5), 0 0 60rpx rgba(255, 212, 229, 0.4);
border-color: rgba(255, 255, 255, 1);
}
}
@keyframes listeningPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes iconBounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4rpx);
}
}
/* 设置弹窗样式 */
.settings-dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 40rpx;
}
.settings-dialog {
width: 92vw;
max-width: 640rpx;
max-height: 80vh;
background: white;
border-radius: 32rpx;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.dialog-header {
padding: 40rpx 40rpx 30rpx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
}
.dialog-title {
font-size: 36rpx;
font-weight: bold;
color: white;
}
.dialog-close {
font-size: 40rpx;
color: white;
opacity: 0.9;
padding: 0 10rpx;
font-weight: 300;
}
.dialog-content {
flex: 1;
overflow-y: auto;
padding: 32rpx;
box-sizing: border-box;
}
.dialog-section {
margin-bottom: 30rpx;
}
.section-label {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 12rpx;
}
.section-hint {
font-size: 24rpx;
color: #999;
margin-bottom: 24rpx;
display: block;
line-height: 1.6;
}
.memory-item {
margin-bottom: 24rpx;
width: 100%;
}
.memory-label {
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
display: block;
font-weight: 500;
}
.memory-input {
width: 100%;
padding: 28rpx 24rpx;
min-height: 88rpx;
background: #FDF8F2;
border-radius: 16rpx;
font-size: 28rpx;
border: 2rpx solid #e8dcc8;
transition: all 0.3s;
box-sizing: border-box;
max-width: 100%;
}
.memory-input:focus {
border-color: #8B7355;
background: white;
}
.memory-textarea {
width: 100%;
min-height: 140rpx;
max-height: none;
padding: 24rpx;
background: #FDF8F2;
border-radius: 16rpx;
font-size: 28rpx;
border: 2rpx solid #e8dcc8;
line-height: 1.6;
transition: all 0.3s;
box-sizing: border-box;
max-width: 100%;
}
.memory-textarea:focus {
border-color: #8B7355;
background: white;
}
.dialog-footer {
padding: 30rpx 40rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
display: flex;
gap: 20rpx;
border-top: 1rpx solid #f0f0f0;
background: #fafafa;
}
.dialog-btn {
flex: 1;
height: 88rpx;
border: none;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.dialog-btn.cancel {
background: #f5f5f5;
color: #666;
}
.dialog-btn.cancel:active {
background: #e0e0e0;
}
.dialog-btn.confirm {
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
color: white;
box-shadow: 0 8rpx 20rpx rgba(139, 115, 85, 0.3);
}
.dialog-btn.confirm:active {
transform: translateY(-2rpx);
box-shadow: 0 12rpx 28rpx rgba(139, 115, 85, 0.4);
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes statusBlink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes recordingPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 8rpx 16rpx rgba(235, 51, 73, 0.3);
}
50% {
transform: scale(1.02);
box-shadow: 0 12rpx 24rpx rgba(235, 51, 73, 0.5),
0 0 0 8rpx rgba(235, 51, 73, 0.2);
}
}
/* 接通中遮罩层 */
.connecting-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
}
/* 模糊背景 */
.connecting-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.bg-avatar {
width: 100%;
height: 100%;
filter: blur(40rpx) brightness(0.4);
transform: scale(1.2);
}
/* 接通中内容 */
.connecting-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 40rpx;
padding: 60rpx;
}
/* 头像容器 */
.avatar-container {
width: 240rpx;
height: 240rpx;
border-radius: 50%;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
border: 6rpx solid rgba(255, 255, 255, 0.3);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
animation: avatarPulse 2s ease-in-out infinite;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 80rpx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
}
/* 名称 */
.connecting-name {
font-size: 40rpx;
font-weight: 600;
color: white;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.5);
}
/* 状态区域 */
.connecting-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
/* 状态点动画 */
.status-dots {
display: flex;
gap: 12rpx;
align-items: center;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: white;
animation: dotBounce 1.4s ease-in-out infinite;
}
.dot-1 {
animation-delay: 0s;
}
.dot-2 {
animation-delay: 0.2s;
}
.dot-3 {
animation-delay: 0.4s;
}
.status-text {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
}
/* 隐藏视频 */
.video-hidden {
opacity: 0;
pointer-events: none;
}
/* 头像脉冲动画 */
@keyframes avatarPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5);
}
50% {
transform: scale(1.05);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5),
0 0 0 20rpx rgba(255, 255, 255, 0.1);
}
}
/* 点跳动动画 */
@keyframes dotBounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 1;
}
40% {
transform: translateY(-16rpx);
opacity: 0.7;
}
}
/* #ifdef MP-WEIXIN */
.video-fullscreen {
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.35);
}
.call-controls {
backdrop-filter: none;
}
.hint-box {
backdrop-filter: none;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.35);
}
.btn-circle {
backdrop-filter: none;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.4);
}
.speak-button-inner {
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.35);
}
.listening-chip {
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.35);
}
.listening-icon {
filter: none;
}
.bg-avatar {
filter: blur(16rpx) brightness(0.4);
}
/* #endif */
</style>