|
@@ -132,6 +132,7 @@ const isRecording = ref(false);
|
|
|
const isCancelling = ref(false);
|
|
const isCancelling = ref(false);
|
|
|
const recordingDuration = ref(0);
|
|
const recordingDuration = ref(0);
|
|
|
const isTranscribing = ref(false);
|
|
const isTranscribing = ref(false);
|
|
|
|
|
+const isRequestingMic = ref(false);
|
|
|
|
|
|
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
|
let audioChunks: Blob[] = [];
|
|
let audioChunks: Blob[] = [];
|
|
@@ -142,8 +143,19 @@ let isStartingRecording = false; // 防止重复启动锁
|
|
|
const CANCEL_THRESHOLD = 80;
|
|
const CANCEL_THRESHOLD = 80;
|
|
|
const MAX_DURATION = 60;
|
|
const MAX_DURATION = 60;
|
|
|
const MIN_DURATION = 1;
|
|
const MIN_DURATION = 1;
|
|
|
|
|
+const MEDIA_TIMEOUT = 60000; // getUserMedia 超时保护(60秒,兼容 iOS 权限弹窗操作时间)
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
|
|
+ // 旧 API 回退:部分旧版 WebView 使用 webkitGetUserMedia
|
|
|
|
|
+ if (!navigator.mediaDevices && (navigator as any).webkitGetUserMedia) {
|
|
|
|
|
+ (navigator as any).mediaDevices = {
|
|
|
|
|
+ getUserMedia: (constraints: any) =>
|
|
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
|
|
+ (navigator as any).webkitGetUserMedia(constraints, resolve, reject);
|
|
|
|
|
+ })
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 检测浏览器是否支持录音
|
|
// 检测浏览器是否支持录音
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
voiceSupported.value = false;
|
|
voiceSupported.value = false;
|
|
@@ -184,6 +196,7 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
|
|
|
// 防止重复启动(mousedown + touchstart 同时触发 / 快速重复点击)
|
|
// 防止重复启动(mousedown + touchstart 同时触发 / 快速重复点击)
|
|
|
if (isStartingRecording || isRecording.value || isTranscribing.value || props.isLoading) return;
|
|
if (isStartingRecording || isRecording.value || isTranscribing.value || props.isLoading) return;
|
|
|
isStartingRecording = true;
|
|
isStartingRecording = true;
|
|
|
|
|
+ isRequestingMic.value = true; // 立即给出视觉反馈,不等待 getUserMedia
|
|
|
|
|
|
|
|
startY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
startY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
|
isCancelling.value = false;
|
|
isCancelling.value = false;
|
|
@@ -197,10 +210,17 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
|
|
+ // 用 Promise.race 包装,防止 getUserMedia 永远 pending(Android WebView 未配置 onPermissionRequest 时)
|
|
|
|
|
+ const getUserMediaPromise = navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
+ const timeoutPromise = new Promise<MediaStream>((_, reject) =>
|
|
|
|
|
+ setTimeout(() => reject(new Error('MEDIA_TIMEOUT')), MEDIA_TIMEOUT)
|
|
|
|
|
+ );
|
|
|
|
|
+ const audioStream = await Promise.race([getUserMediaPromise, timeoutPromise]);
|
|
|
|
|
+ isRequestingMic.value = false;
|
|
|
|
|
|
|
|
// getUserMedia 是异步的,等待期间用户可能已松手,此时应放弃录音
|
|
// getUserMedia 是异步的,等待期间用户可能已松手,此时应放弃录音
|
|
|
if (!isStartingRecording) {
|
|
if (!isStartingRecording) {
|
|
|
|
|
+ isRequestingMic.value = false;
|
|
|
audioStream.getTracks().forEach(track => track.stop());
|
|
audioStream.getTracks().forEach(track => track.stop());
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
@@ -228,8 +248,14 @@ const startRecording = async (event: MouseEvent | TouchEvent) => {
|
|
|
}
|
|
}
|
|
|
}, 200);
|
|
}, 200);
|
|
|
} catch (err: any) {
|
|
} catch (err: any) {
|
|
|
- console.error('无法获取麦克风权限:', err);
|
|
|
|
|
- alert('请允许麦克风权限以使用语音功能');
|
|
|
|
|
|
|
+ isRequestingMic.value = false;
|
|
|
|
|
+ isStartingRecording = false;
|
|
|
|
|
+ if (err.message === 'MEDIA_TIMEOUT') {
|
|
|
|
|
+ alert('麦克风权限请求超时,请检查系统设置中是否已授权麦克风权限');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.error('无法获取麦克风权限:', err);
|
|
|
|
|
+ alert('请允许麦克风权限以使用语音功能');
|
|
|
|
|
+ }
|
|
|
} finally {
|
|
} finally {
|
|
|
isStartingRecording = false;
|
|
isStartingRecording = false;
|
|
|
}
|
|
}
|
|
@@ -466,7 +492,14 @@ const sendVoice = async (audioBlob: Blob) => {
|
|
|
@touchend.prevent="stopRecording"
|
|
@touchend.prevent="stopRecording"
|
|
|
@touchmove.prevent="handleTouchMove"
|
|
@touchmove.prevent="handleTouchMove"
|
|
|
>
|
|
>
|
|
|
- <span v-if="!isRecording && !isTranscribing" class="text-gray-400">按住说话</span>
|
|
|
|
|
|
|
+ <span v-if="isRequestingMic" class="text-yellow-400 flex items-center gap-2">
|
|
|
|
|
+ <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
|
|
|
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 正在请求麦克风...
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span v-else-if="!isRecording && !isTranscribing" class="text-gray-400">按住说话</span>
|
|
|
<span v-else-if="isTranscribing" class="text-blue-400 flex items-center gap-2">
|
|
<span v-else-if="isTranscribing" class="text-blue-400 flex items-center gap-2">
|
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|