3836 lines
101 KiB
Vue
3836 lines
101 KiB
Vue
<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>
|
||
|
||
<!-- AI生成提示标签 -->
|
||
<view class="ai-tag">
|
||
<text class="ai-tag-text">AI生成</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);
|
||
}
|
||
}
|
||
|
||
/* AI生成提示标签 */
|
||
.ai-tag {
|
||
position: absolute;
|
||
top: 100rpx;
|
||
right: 24rpx;
|
||
z-index: 100;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
padding: 8rpx 20rpx;
|
||
border-radius: 30rpx;
|
||
backdrop-filter: blur(10rpx);
|
||
}
|
||
|
||
.ai-tag-text {
|
||
font-size: 22rpx;
|
||
color: #fff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 点跳动动画 */
|
||
@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>
|