/** * 麦克风权限申请与错误文案映射。 * * 背景:getUserMedia 失败时抛出的是 Chromium 英文原文(实测见过 * "Could not start audio source"),直接透给用户不可读,故统一翻译成可执行的中文引导。 * * 权限预申请是防御性的:未在"权限未授予"的设备上实测过 WebView 是否会自行弹出 * Android 运行时授权框。已授权设备上本调用为无害空转;若确实不弹,则它是唯一的授权入口。 */ const RECORD_AUDIO = 'android.permission.RECORD_AUDIO'; /** 授权申请超时:回调未触发时不能无限挂起,放行交由 getUserMedia 兜底。 */ const REQUEST_TIMEOUT_MS = 15000; export type MicPermissionResult = 'granted' | 'denied' | 'denied-always' | 'unsupported'; interface PlusAndroidLike { requestPermissions?: ( permissions: string[], success: (event: { granted?: string[]; deniedPresent?: string[]; deniedAlways?: string[] }) => void, fail: (error: unknown) => void ) => void; } const androidBridge = (): PlusAndroidLike | null => { const plusApi = (globalThis as typeof globalThis & { plus?: { android?: PlusAndroidLike } }).plus; return plusApi?.android ?? null; }; /** * 主动申请麦克风权限。 * 非 Android App 运行时(H5、iOS)返回 unsupported,由各自平台的原生授权流程处理。 */ export const requestMicrophonePermission = (): Promise => { const android = androidBridge(); if (!android?.requestPermissions) return Promise.resolve('unsupported'); return new Promise((resolve) => { let settled = false; const settle = (result: MicPermissionResult) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); }; const timer = setTimeout(() => settle('unsupported'), REQUEST_TIMEOUT_MS); try { android.requestPermissions!( [RECORD_AUDIO], (event) => { if (event?.granted?.length) settle('granted'); else if (event?.deniedAlways?.length) settle('denied-always'); else if (event?.deniedPresent?.length) settle('denied'); else settle('denied'); }, () => settle('unsupported') ); } catch { settle('unsupported'); } }); }; /** 权限被拒时的中文引导文案。 */ export const microphoneDeniedMessage = (result: MicPermissionResult) => result === 'denied-always' ? '麦克风权限已被永久拒绝,请到系统设置 › 应用 › 权限中开启麦克风后重试。' : '需要麦克风权限才能实时对话,请在弹出的授权提示中选择允许。'; /** * 把底层音频/WebRTC 错误翻译成中文引导。 * 这些原文来自 Chromium 与 WebRTC,直接展示给用户不可读。 */ export const describeMicrophoneError = (error: unknown, fallback: string) => { const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; const name = error instanceof Error ? error.name : ''; const signal = `${name} ${raw}`.toLowerCase(); // 权限类:系统未授予,或用户拒绝。 if (signal.includes('notallowed') || signal.includes('permission')) { return '麦克风权限未开启,请到系统设置 › 应用 › 权限中允许使用麦克风后重试。'; } // 设备占用:通常是其他应用(通话、录音)正在使用麦克风。 if (signal.includes('could not start audio source') || signal.includes('notreadable') || signal.includes('trackstart')) { return '麦克风被占用或无法启动,请关闭正在通话/录音的应用,并确认已授予麦克风权限后重试。'; } // 无可用录音设备。 if (signal.includes('notfound') || signal.includes('devices not found')) { return '未检测到可用的麦克风设备,请改用录音对练。'; } // 非安全上下文:WebView 未以 https/localhost 承载时 getUserMedia 不可用。 if (signal.includes('secure') || signal.includes('ssl')) { return '当前运行环境不支持实时语音,请改用录音对练。'; } // 已是中文文案(业务层自己抛的)时原样透出,否则回落到通用提示。 return /[一-龥]/.test(raw) ? raw : fallback; };