import { reactive, shallowRef, ref } from 'vue' import * as systemApi from '@/api/modules/system' import * as messageApi from '@/api/modules/message' import { wsManager } from '@/api/websocket' import { useToastStore } from '@/stores/toast' import { useChatStore } from '@/stores/chat' import type { ChatMessage } from '@/types/api' import type { CallStatus } from '@/types/message' export interface CallState { active: boolean minimized: boolean type: 'audio' | 'video' status: 'idle' | 'outgoing' | 'incoming' | 'connected' statusText: string id: string | null muted: boolean remoteMuted: boolean // 新增:对方是否静音 camOff: boolean remoteCamOff: boolean duration: number startTime: number | null } // 使用 Base64 数据 URI 避免 403 和跨域问题 // 简短的电话铃声 (Incoming) const RINGTONE_INCOMING_BASE64 = 'data:audio/mp3;base64,//uQxAAAAAAAAAAAAEluZm8AAAAPAAAAHgAABOYADQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NAAAAAAJDTEFtZTMuMTAwBK8AAAAAAAAAABQJAAHIAAAAHgAABObK82LdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAAAAAAABAAAAAAAAAAASbXAzLm9yZwAAAP8AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAEAMzI2hAAAAANIAAAAQAAAEAQGBAIAQBAEAQCBwAAAAe3/8w9///3v//+57//3///8w9///3v//+57//3///8AAKAAX/4EFwR//uQxBEAM8I2fAAAAANIAAAAQAAAEAAAAAAB///+BACAAAAAAH/9R//qD//6g////+oAAAD/4IHgAAAAAA//6gAAAAAT//uQxBIAOQI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxBsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxCsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxDQAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxF4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxGwAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxHgAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxIUAAANIAAAAAQAIAAAB//////////////////////////////////////////////////////////////////////////////////uQxIkAAANIAAAAAQAIAAAB////////////////////////////////////////////////////////////////////////////////' // 简短的嘟嘟拨号音 (Dialing) const RINGTONE_DIALING_BASE64 = 'data:audio/wav;base64,UklGRl9vT1BXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU' export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) { const toastStore = useToastStore() const chatStore = useChatStore() const call = reactive({ active: false, minimized: false, type: 'video', status: 'idle', statusText: '', id: null, muted: false, remoteMuted: false, // 初始化 camOff: false, remoteCamOff: false, duration: 0, startTime: null, }) const isCaller = ref(false) const localStream = shallowRef(null) const remoteStream = shallowRef(null) let durationTimer: number | null = null let pc: RTCPeerConnection | null = null const pendingCandidates: RTCIceCandidate[] = [] let currentReceiverUserId = '' // 音频对象初始化 (使用 Base64) const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64) const audioDialing = new Audio(RINGTONE_DIALING_BASE64) // 使用 Base64 或简单的音频 // 由于 Base64 字符串较短,需要循环播放 audioIncoming.loop = true audioDialing.loop = true // 简单的嘟嘟声生成器 (如果 Base64 不工作作为备选,但通常 Base64 更可靠) // 这里直接使用 Audio Context 生成嘟嘟声可能更专业,但为了兼容性先用 Audio 标签 // 注意:上面的 Base64 只是示例片段,为了确保有声音,建议用真实的短音频 Base64 // --- 铃声控制 --- function playRingtone(type: 'incoming' | 'dialing') { stopRingtone() // 先停止当前的 // 如果是拨号音,我们可以用 Web Audio API 生成一个标准的嘟嘟声,这样不需要加载文件 if (type === 'dialing') { playOscillatorTone() return } const audio = audioIncoming audio.currentTime = 0 audio.play().catch(e => console.warn('Autoplay prevented:', e)) } // 使用 Web Audio API 生成拨号音 (避免资源 403 问题) let oscCtx: AudioContext | null = null let oscillator: OscillatorNode | null = null let gainNode: GainNode | null = null function playOscillatorTone() { try { const AudioContext = window.AudioContext || (window as any).webkitAudioContext if (!AudioContext) return oscCtx = new AudioContext() oscillator = oscCtx.createOscillator() gainNode = oscCtx.createGain() oscillator.type = 'sine' oscillator.frequency.setValueAtTime(440, oscCtx.currentTime) // 440Hz 标准音 // 模拟嘟-嘟-嘟的效果 gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime) oscillator.connect(gainNode) gainNode.connect(oscCtx.destination) oscillator.start() // 简单的循环效果 const pulse = () => { if(!gainNode || !oscCtx) return gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime) setTimeout(() => { if(gainNode && oscCtx) gainNode.gain.setValueAtTime(0, oscCtx.currentTime) }, 800) setTimeout(pulse, 2000) } pulse() } catch(e) { console.error(e) } } function stopRingtone() { audioIncoming.pause() audioIncoming.currentTime = 0 // 停止 Web Audio API 的声音 if (oscillator) { try { oscillator.stop(); oscillator.disconnect() } catch(e){} oscillator = null } if (gainNode) { try { gainNode.disconnect() } catch(e){} gainNode = null } if (oscCtx) { try { oscCtx.close() } catch(e){} oscCtx = null } } // --- 系统通知 --- function sendSystemNotification(title: string, body: string) { if (!('Notification' in window)) return // 再次尝试请求权限 (如果是用户触发的操作中调用) if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { Notification.requestPermission().then(permission => { if (permission === 'granted') { new Notification(title, { body, icon: '/favicon.ico' }) } }) } else if (Notification.permission === 'granted') { new Notification(title, { body, icon: '/favicon.ico' }) } } // --- 辅助函数 --- function getRoomId(uid1: string, uid2: string): string { return [uid1, uid2].sort().join('_') } function formatTextDuration(seconds: number): string { if (seconds <= 0) return '0秒' const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) const s = seconds % 60 let str = '' if (h > 0) str += `${h}小时` if (m > 0) str += `${m}分钟` if (s > 0) str += `${s}秒` return str } async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') { if (!isCaller.value || !currentReceiverUserId) return let content = '' const durationStr = formatTextDuration(call.duration) const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) const deviceText = isMobile ? '移动端' : '电脑端' if (reason === 'connected') content = `通话结束,时长:${durationStr}` else if (reason === 'cancelled') content = '已取消呼叫' else if (reason === 'rejected') content = '对方拒绝接听' else if (reason === 'busy') content = '对方忙' content += ` [${deviceText}]` const roomId = getRoomId(userId, currentReceiverUserId) const clientId = wsManager.getClientId() const payload = { sender_client_id: clientId || '', receiver_user_id: currentReceiverUserId, room_id: roomId, message_type: 0, content: content, duration: 0, extra: JSON.stringify({ isSystem: true }), } try { await messageApi.sendMessage(payload) const localMsg: any = { ...payload, id: Date.now(), sender_user_id: userId, created_at: new Date().toISOString(), isSelf: true, extra: { isSystem: true } } chatStore.addMessage(roomId, localMsg) chatStore.updateContactLastMsg(currentReceiverUserId, content, Date.now()) } catch (e) { console.error(e) } } // --- WebRTC 基础 --- async function initMedia(videoEnabled: boolean): Promise { try { if (localStream.value) localStream.value.getTracks().forEach(t => t.stop()) const constraints: MediaStreamConstraints = { video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' } : false, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, } const stream = await navigator.mediaDevices.getUserMedia(constraints) localStream.value = stream } catch (error) { throw new Error('无法获取设备权限') } } async function createPC(): Promise { const servers = [{ urls: 'stun:stun.l.google.com:19302' }] if (pc) pc.close() pc = new RTCPeerConnection({ iceServers: servers }) if (localStream.value) localStream.value.getTracks().forEach((track) => pc!.addTrack(track, localStream.value!)) pc.ontrack = (e) => { if (e.streams && e.streams[0]) remoteStream.value = e.streams[0] } pc.onicecandidate = (e) => { if (e.candidate && call.id) sendSignal('candidate', e.candidate) } } function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) { if (!call.id) return const targetUserId = receiverUserId || currentReceiverUserId if (!targetUserId) return const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: targetUserId, room_id: '', message_type: 6, content: JSON.stringify(data || {}), call_id: call.id, call_status: status, extra: JSON.stringify({ type: call.type }), } messageApi.sendMessage(payload).catch(console.error) } // --- 状态同步信令 (摄像头/麦克风) --- function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) { if (call.status !== 'connected') return const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: currentReceiverUserId, room_id: '', message_type: 6, content: JSON.stringify({ action: type, value }), call_id: call.id, call_status: 'sync_state' as any, extra: JSON.stringify({ type: call.type }) } messageApi.sendMessage(payload).catch(console.error) } // --- 计时器 --- function startCallTimer() { stopCallTimer() call.startTime = Date.now() call.duration = 0 durationTimer = window.setInterval(() => { if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000) }, 1000) } function stopCallTimer() { if (durationTimer) { clearInterval(durationTimer); durationTimer = null } call.startTime = null call.duration = 0 } function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60).toString().padStart(2, '0') const s = (seconds % 60).toString().padStart(2, '0') const h = Math.floor(seconds / 3600) return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}` } // --- 操作 --- async function startCall(type: 'audio' | 'video', receiverUserId: string) { isCaller.value = true currentReceiverUserId = receiverUserId call.type = type call.id = Date.now().toString() call.active = true call.minimized = false call.status = 'outgoing' call.statusText = '正在呼叫...' call.duration = 0 call.camOff = false call.remoteCamOff = false call.remoteMuted = false // 重置 remoteStream.value = null try { // 立即获取本地媒体流,确保自己能看到自己 await initMedia(type === 'video') await createPC() playRingtone('dialing') sendSignal('invite', undefined, receiverUserId) } catch (error: any) { toastStore.error(error.message || '无法启动通话') closeCall() } } async function acceptCall(senderUserId?: string) { isCaller.value = false stopRingtone() if (senderUserId) currentReceiverUserId = senderUserId call.status = 'connected' call.statusText = '正在连接...' call.camOff = false call.remoteCamOff = false call.remoteMuted = false try { await initMedia(call.type === 'video') await createPC() sendSignal('accepted', undefined, currentReceiverUserId) } catch (error) { endCall() } } function endCall() { stopRingtone() if (isCaller.value) { if (call.status === 'connected') sendSummaryMessage('connected') else if (call.status === 'outgoing') sendSummaryMessage('cancelled') } sendSignal('hangup') closeCall() } function closeCall() { stopRingtone() call.active = false call.status = 'idle' call.statusText = '' call.id = null stopCallTimer() if (pc) { pc.close(); pc = null } if (localStream.value) { localStream.value.getTracks().forEach(t => t.stop()); localStream.value = null } remoteStream.value = null } async function handleSignaling(message: ChatMessage) { const signal = message.call_status as any const content = message.content ? JSON.parse(message.content) : {} const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {} if (extra.type) call.type = extra.type // 处理状态同步 if (signal === 'sync_state') { if (content.action === 'cam-toggle') { call.remoteCamOff = content.value } else if (content.action === 'mic-toggle') { call.remoteMuted = content.value } return } if (signal === 'invite') { isCaller.value = false currentReceiverUserId = message.sender_user_id call.id = message.call_id call.active = true call.minimized = false call.status = 'incoming' call.statusText = `邀请你进行${call.type === 'video' ? '视频' : '语音'}通话` call.remoteCamOff = false call.remoteMuted = false playRingtone('incoming') sendSystemNotification('新来电', `收到来自 ${message.sender_user_id} 的${call.type === 'video' ? '视频' : '语音'}通话邀请`) if (onIncomingCall) onIncomingCall(message.sender_user_id) } else if (signal === 'accepted') { stopRingtone() call.status = 'connected' call.statusText = '通话中' startCallTimer() const offer = await pc!.createOffer() await pc!.setLocalDescription(offer) sendSignal('offer', offer) } else if (signal === 'offer') { stopRingtone() if (!pc) { await initMedia(call.type === 'video') await createPC() } await pc!.setRemoteDescription(content) processPendingCandidates() const answer = await pc!.createAnswer() await pc!.setLocalDescription(answer) sendSignal('answer', answer, message.sender_user_id) call.status = 'connected' call.statusText = '通话中' startCallTimer() } else if (signal === 'answer') { await pc!.setRemoteDescription(content) processPendingCandidates() } else if (signal === 'candidate') { if (pc && pc.remoteDescription) await pc.addIceCandidate(content) else pendingCandidates.push(content) } else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) { if (isCaller.value) { if (call.status === 'outgoing') sendSummaryMessage('rejected') else if (call.status === 'connected') sendSummaryMessage('connected') } closeCall() if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听') } } async function processPendingCandidates() { while (pendingCandidates.length > 0) { const c = pendingCandidates.shift() if (c && pc) await pc.addIceCandidate(c) } } function toggleMute() { call.muted = !call.muted sendSyncState('mic-toggle', call.muted) // 同步 if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted) } function toggleCamera() { call.camOff = !call.camOff sendSyncState('cam-toggle', call.camOff) // 同步 if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff) } return { call, localStream, remoteStream, startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, formatDuration, sendSystemNotification } }