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 camOff: boolean remoteCamOff: boolean // 新增:对方是否关闭摄像头 duration: number startTime: number | null } // 铃声资源 (使用在线资源作为示例) const RINGTONE_INCOMING = 'https://assets.mixkit.co/active_storage/sfx/2860/2860-64-bit.mp3' // 电话铃声 const RINGTONE_DIALING = 'https://assets.mixkit.co/active_storage/sfx/2864/2864-64-bit.mp3' // 嘟嘟声 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, 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 = '' // 音频对象 const audioIncoming = new Audio(RINGTONE_INCOMING) const audioDialing = new Audio(RINGTONE_DIALING) audioIncoming.loop = true audioDialing.loop = true // --- 铃声控制 --- function playRingtone(type: 'incoming' | 'dialing') { stopRingtone() // 先停止当前的 const audio = type === 'incoming' ? audioIncoming : audioDialing audio.currentTime = 0 audio.play().catch(e => console.warn('Autoplay prevented:', e)) } function stopRingtone() { audioIncoming.pause() audioDialing.pause() audioIncoming.currentTime = 0 audioDialing.currentTime = 0 } // --- 系统通知 --- function sendSystemNotification(title: string, body: string) { if (!('Notification' in window)) return if (Notification.permission === 'granted') { new Notification(title, { body, icon: '/favicon.ico' }) } else if (Notification.permission !== 'denied') { Notification.requestPermission().then(permission => { if (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', 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 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 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 } 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 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 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 } }