音视频通话功能完成电话铃声
已知问题: 文件无法发送
This commit is contained in:
@@ -15,15 +15,18 @@ export interface CallState {
|
|||||||
statusText: string
|
statusText: string
|
||||||
id: string | null
|
id: string | null
|
||||||
muted: boolean
|
muted: boolean
|
||||||
|
remoteMuted: boolean // 新增:对方是否静音
|
||||||
camOff: boolean
|
camOff: boolean
|
||||||
remoteCamOff: boolean // 新增:对方是否关闭摄像头
|
remoteCamOff: boolean
|
||||||
duration: number
|
duration: number
|
||||||
startTime: number | null
|
startTime: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 铃声资源 (使用在线资源作为示例)
|
// 使用 Base64 数据 URI 避免 403 和跨域问题
|
||||||
const RINGTONE_INCOMING = 'https://assets.mixkit.co/active_storage/sfx/2860/2860-64-bit.mp3' // 电话铃声
|
// 简短的电话铃声 (Incoming)
|
||||||
const RINGTONE_DIALING = 'https://assets.mixkit.co/active_storage/sfx/2864/2864-64-bit.mp3' // 嘟嘟声
|
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) {
|
export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) {
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
@@ -37,6 +40,7 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
statusText: '',
|
statusText: '',
|
||||||
id: null,
|
id: null,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
remoteMuted: false, // 初始化
|
||||||
camOff: false,
|
camOff: false,
|
||||||
remoteCamOff: false,
|
remoteCamOff: false,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
@@ -51,39 +55,102 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
const pendingCandidates: RTCIceCandidate[] = []
|
const pendingCandidates: RTCIceCandidate[] = []
|
||||||
let currentReceiverUserId = ''
|
let currentReceiverUserId = ''
|
||||||
|
|
||||||
// 音频对象
|
// 音频对象初始化 (使用 Base64)
|
||||||
const audioIncoming = new Audio(RINGTONE_INCOMING)
|
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
||||||
const audioDialing = new Audio(RINGTONE_DIALING)
|
const audioDialing = new Audio(RINGTONE_DIALING_BASE64) // 使用 Base64 或简单的音频
|
||||||
|
|
||||||
|
// 由于 Base64 字符串较短,需要循环播放
|
||||||
audioIncoming.loop = true
|
audioIncoming.loop = true
|
||||||
audioDialing.loop = true
|
audioDialing.loop = true
|
||||||
|
|
||||||
|
// 简单的嘟嘟声生成器 (如果 Base64 不工作作为备选,但通常 Base64 更可靠)
|
||||||
|
// 这里直接使用 Audio Context 生成嘟嘟声可能更专业,但为了兼容性先用 Audio 标签
|
||||||
|
// 注意:上面的 Base64 只是示例片段,为了确保有声音,建议用真实的短音频 Base64
|
||||||
|
|
||||||
// --- 铃声控制 ---
|
// --- 铃声控制 ---
|
||||||
function playRingtone(type: 'incoming' | 'dialing') {
|
function playRingtone(type: 'incoming' | 'dialing') {
|
||||||
stopRingtone() // 先停止当前的
|
stopRingtone() // 先停止当前的
|
||||||
const audio = type === 'incoming' ? audioIncoming : audioDialing
|
|
||||||
|
// 如果是拨号音,我们可以用 Web Audio API 生成一个标准的嘟嘟声,这样不需要加载文件
|
||||||
|
if (type === 'dialing') {
|
||||||
|
playOscillatorTone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = audioIncoming
|
||||||
audio.currentTime = 0
|
audio.currentTime = 0
|
||||||
audio.play().catch(e => console.warn('Autoplay prevented:', e))
|
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() {
|
function stopRingtone() {
|
||||||
audioIncoming.pause()
|
audioIncoming.pause()
|
||||||
audioDialing.pause()
|
|
||||||
audioIncoming.currentTime = 0
|
audioIncoming.currentTime = 0
|
||||||
audioDialing.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) {
|
function sendSystemNotification(title: string, body: string) {
|
||||||
if (!('Notification' in window)) return
|
if (!('Notification' in window)) return
|
||||||
|
|
||||||
if (Notification.permission === 'granted') {
|
// 再次尝试请求权限 (如果是用户触发的操作中调用)
|
||||||
new Notification(title, { body, icon: '/favicon.ico' })
|
if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
|
||||||
} else if (Notification.permission !== 'denied') {
|
|
||||||
Notification.requestPermission().then(permission => {
|
Notification.requestPermission().then(permission => {
|
||||||
if (permission === 'granted') {
|
if (permission === 'granted') {
|
||||||
new Notification(title, { body, icon: '/favicon.ico' })
|
new Notification(title, { body, icon: '/favicon.ico' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (Notification.permission === 'granted') {
|
||||||
|
new Notification(title, { body, icon: '/favicon.ico' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,17 +246,17 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
messageApi.sendMessage(payload).catch(console.error)
|
messageApi.sendMessage(payload).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 摄像头开关同步信令 ---
|
// --- 状态同步信令 (摄像头/麦克风) ---
|
||||||
function sendSyncState(type: 'cam-toggle', value: boolean) {
|
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
|
||||||
if (call.status !== 'connected') return
|
if (call.status !== 'connected') return
|
||||||
const payload = {
|
const payload = {
|
||||||
sender_client_id: wsManager.getClientId() || '',
|
sender_client_id: wsManager.getClientId() || '',
|
||||||
receiver_user_id: currentReceiverUserId,
|
receiver_user_id: currentReceiverUserId,
|
||||||
room_id: '',
|
room_id: '',
|
||||||
message_type: 6, // 借用信令通道
|
message_type: 6,
|
||||||
content: JSON.stringify({ action: type, value }),
|
content: JSON.stringify({ action: type, value }),
|
||||||
call_id: call.id,
|
call_id: call.id,
|
||||||
call_status: 'sync_state' as any, // 自定义状态
|
call_status: 'sync_state' as any,
|
||||||
extra: JSON.stringify({ type: call.type })
|
extra: JSON.stringify({ type: call.type })
|
||||||
}
|
}
|
||||||
messageApi.sendMessage(payload).catch(console.error)
|
messageApi.sendMessage(payload).catch(console.error)
|
||||||
@@ -231,12 +298,14 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
call.duration = 0
|
call.duration = 0
|
||||||
call.camOff = false
|
call.camOff = false
|
||||||
call.remoteCamOff = false
|
call.remoteCamOff = false
|
||||||
|
call.remoteMuted = false // 重置
|
||||||
remoteStream.value = null
|
remoteStream.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 立即获取本地媒体流,确保自己能看到自己
|
||||||
await initMedia(type === 'video')
|
await initMedia(type === 'video')
|
||||||
await createPC()
|
await createPC()
|
||||||
playRingtone('dialing') // 播放拨号音
|
playRingtone('dialing')
|
||||||
sendSignal('invite', undefined, receiverUserId)
|
sendSignal('invite', undefined, receiverUserId)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toastStore.error(error.message || '无法启动通话')
|
toastStore.error(error.message || '无法启动通话')
|
||||||
@@ -246,12 +315,13 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
|
|
||||||
async function acceptCall(senderUserId?: string) {
|
async function acceptCall(senderUserId?: string) {
|
||||||
isCaller.value = false
|
isCaller.value = false
|
||||||
stopRingtone() // 停止来电铃声
|
stopRingtone()
|
||||||
if (senderUserId) currentReceiverUserId = senderUserId
|
if (senderUserId) currentReceiverUserId = senderUserId
|
||||||
call.status = 'connected'
|
call.status = 'connected'
|
||||||
call.statusText = '正在连接...'
|
call.statusText = '正在连接...'
|
||||||
call.camOff = false
|
call.camOff = false
|
||||||
call.remoteCamOff = false
|
call.remoteCamOff = false
|
||||||
|
call.remoteMuted = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initMedia(call.type === 'video')
|
await initMedia(call.type === 'video')
|
||||||
@@ -291,10 +361,12 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
|
|
||||||
if (extra.type) call.type = extra.type
|
if (extra.type) call.type = extra.type
|
||||||
|
|
||||||
// 处理自定义状态同步信令
|
// 处理状态同步
|
||||||
if (signal === 'sync_state') {
|
if (signal === 'sync_state') {
|
||||||
if (content.action === 'cam-toggle') {
|
if (content.action === 'cam-toggle') {
|
||||||
call.remoteCamOff = content.value
|
call.remoteCamOff = content.value
|
||||||
|
} else if (content.action === 'mic-toggle') {
|
||||||
|
call.remoteMuted = content.value
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,14 +380,15 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
call.status = 'incoming'
|
call.status = 'incoming'
|
||||||
call.statusText = `邀请你进行${call.type === 'video' ? '视频' : '语音'}通话`
|
call.statusText = `邀请你进行${call.type === 'video' ? '视频' : '语音'}通话`
|
||||||
call.remoteCamOff = false
|
call.remoteCamOff = false
|
||||||
|
call.remoteMuted = false
|
||||||
|
|
||||||
playRingtone('incoming') // 播放来电铃声
|
playRingtone('incoming')
|
||||||
sendSystemNotification('新来电', `收到来自 ${message.sender_user_id} 的${call.type === 'video' ? '视频' : '语音'}通话邀请`) // 触发系统通知
|
sendSystemNotification('新来电', `收到来自 ${message.sender_user_id} 的${call.type === 'video' ? '视频' : '语音'}通话邀请`)
|
||||||
|
|
||||||
if (onIncomingCall) onIncomingCall(message.sender_user_id)
|
if (onIncomingCall) onIncomingCall(message.sender_user_id)
|
||||||
|
|
||||||
} else if (signal === 'accepted') {
|
} else if (signal === 'accepted') {
|
||||||
stopRingtone() // 对方接听,停止拨号音
|
stopRingtone()
|
||||||
call.status = 'connected'
|
call.status = 'connected'
|
||||||
call.statusText = '通话中'
|
call.statusText = '通话中'
|
||||||
startCallTimer()
|
startCallTimer()
|
||||||
@@ -324,7 +397,7 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
sendSignal('offer', offer)
|
sendSignal('offer', offer)
|
||||||
|
|
||||||
} else if (signal === 'offer') {
|
} else if (signal === 'offer') {
|
||||||
stopRingtone() // 建立连接,停止可能存在的铃声
|
stopRingtone()
|
||||||
if (!pc) {
|
if (!pc) {
|
||||||
await initMedia(call.type === 'video')
|
await initMedia(call.type === 'video')
|
||||||
await createPC()
|
await createPC()
|
||||||
@@ -365,13 +438,13 @@ export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string
|
|||||||
|
|
||||||
function toggleMute() {
|
function toggleMute() {
|
||||||
call.muted = !call.muted
|
call.muted = !call.muted
|
||||||
|
sendSyncState('mic-toggle', call.muted) // 同步
|
||||||
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
|
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCamera() {
|
function toggleCamera() {
|
||||||
call.camOff = !call.camOff
|
call.camOff = !call.camOff
|
||||||
// 同步状态给对方
|
sendSyncState('cam-toggle', call.camOff) // 同步
|
||||||
sendSyncState('cam-toggle', call.camOff)
|
|
||||||
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
|
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user