311 lines
11 KiB
JavaScript
311 lines
11 KiB
JavaScript
import { onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
|
|
import { useUserStore } from '@/pinia/modules/user'
|
|
|
|
const websocketURL = (token) => {
|
|
const override = String(import.meta.env.VITE_WEBSOCKET_URL || '').trim()
|
|
const base = override || String(import.meta.env.VITE_BASE_API || '').trim()
|
|
let url
|
|
if (/^wss?:\/\//i.test(base)) url = new URL(base)
|
|
else if (/^https?:\/\//i.test(base)) url = new URL(base)
|
|
else url = new URL(base || '/', window.location.origin)
|
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
if (!override || !/\/ws\/?$/i.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/$/, '')}/ws`.replace(/\/+/g, '/')
|
|
url.searchParams.set('token', token)
|
|
return url.toString()
|
|
}
|
|
|
|
const waitForIce = (pc) => {
|
|
if (pc.iceGatheringState === 'complete') return Promise.resolve()
|
|
return new Promise((resolve) => {
|
|
const listener = () => {
|
|
if (pc.iceGatheringState !== 'complete') return
|
|
pc.removeEventListener('icegatheringstatechange', listener)
|
|
resolve()
|
|
}
|
|
pc.addEventListener('icegatheringstatechange', listener)
|
|
window.setTimeout(resolve, 5000)
|
|
})
|
|
}
|
|
|
|
export function useLiveRTC() {
|
|
const userStore = useUserStore()
|
|
const state = ref('idle')
|
|
const message = ref('')
|
|
const viewerCount = ref(0)
|
|
const remoteStream = shallowRef(null)
|
|
const chatMessages = ref([])
|
|
const events = ref([])
|
|
const diagnostics = reactive({
|
|
websocket: 'closed',
|
|
peerConnection: 'new',
|
|
iceConnection: 'new',
|
|
signaling: 'stable',
|
|
publisherReady: false,
|
|
localTracks: 0,
|
|
remoteTracks: 0,
|
|
bytesSent: 0,
|
|
bytesReceived: 0,
|
|
packetsLost: 0,
|
|
roundTripTime: 0,
|
|
connectedAt: '',
|
|
lastHeartbeatAt: '',
|
|
lastError: '',
|
|
})
|
|
let socket = null
|
|
let pc = null
|
|
let heartbeat = 0
|
|
let statsTimer = 0
|
|
let role = ''
|
|
let streamId = ''
|
|
let localStream = null
|
|
let connectResolve = null
|
|
let connectReject = null
|
|
|
|
const record = (level, text, detail = '') => {
|
|
events.value = [...events.value.slice(-79), { at: new Date().toISOString(), level, text, detail }]
|
|
}
|
|
|
|
const resetDiagnostics = () => {
|
|
Object.assign(diagnostics, {
|
|
websocket: 'closed', peerConnection: 'new', iceConnection: 'new', signaling: 'stable', publisherReady: false,
|
|
localTracks: 0, remoteTracks: 0, bytesSent: 0, bytesReceived: 0, packetsLost: 0, roundTripTime: 0,
|
|
connectedAt: '', lastHeartbeatAt: '', lastError: '',
|
|
})
|
|
events.value = []
|
|
}
|
|
|
|
const send = (type, payload = {}) => {
|
|
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type, payload }))
|
|
}
|
|
|
|
const closePeer = () => {
|
|
window.clearInterval(statsTimer)
|
|
pc?.close()
|
|
pc = null
|
|
remoteStream.value = null
|
|
diagnostics.peerConnection = 'closed'
|
|
diagnostics.iceConnection = 'closed'
|
|
diagnostics.remoteTracks = 0
|
|
}
|
|
|
|
const collectStats = async () => {
|
|
if (!pc) return
|
|
const reports = await pc.getStats()
|
|
let sent = 0
|
|
let received = 0
|
|
let lost = 0
|
|
let roundTrip = 0
|
|
reports.forEach((report) => {
|
|
if (report.type === 'outbound-rtp' && !report.isRemote) sent += Number(report.bytesSent || 0)
|
|
if (report.type === 'inbound-rtp' && !report.isRemote) {
|
|
received += Number(report.bytesReceived || 0)
|
|
lost += Number(report.packetsLost || 0)
|
|
}
|
|
if (report.type === 'candidate-pair' && report.state === 'succeeded' && (report.nominated || report.selected)) {
|
|
roundTrip = Number(report.currentRoundTripTime || 0)
|
|
}
|
|
})
|
|
diagnostics.bytesSent = sent
|
|
diagnostics.bytesReceived = received
|
|
diagnostics.packetsLost = lost
|
|
diagnostics.roundTripTime = roundTrip
|
|
}
|
|
|
|
const startStats = () => {
|
|
window.clearInterval(statsTimer)
|
|
collectStats().catch(() => {})
|
|
statsTimer = window.setInterval(() => collectStats().catch(() => {}), 1000)
|
|
}
|
|
|
|
const negotiate = async () => {
|
|
closePeer()
|
|
const peer = new RTCPeerConnection()
|
|
pc = peer
|
|
diagnostics.localTracks = role === 'publisher' ? (localStream?.getTracks().length || 0) : 0
|
|
diagnostics.peerConnection = peer.connectionState
|
|
diagnostics.iceConnection = peer.iceConnectionState
|
|
diagnostics.signaling = peer.signalingState
|
|
record('info', role === 'publisher' ? '创建主播 PeerConnection' : '创建观众 PeerConnection')
|
|
peer.onconnectionstatechange = () => {
|
|
if (pc !== peer) return
|
|
diagnostics.peerConnection = peer.connectionState
|
|
record(peer.connectionState === 'failed' ? 'error' : 'info', `PeerConnection: ${diagnostics.peerConnection}`)
|
|
if (peer.connectionState === 'connected') {
|
|
state.value = 'connected'
|
|
message.value = ''
|
|
diagnostics.connectedAt = new Date().toISOString()
|
|
startStats()
|
|
}
|
|
if (['failed', 'disconnected'].includes(peer.connectionState)) {
|
|
state.value = 'waiting'
|
|
message.value = '连接中断,正在等待恢复'
|
|
}
|
|
}
|
|
peer.oniceconnectionstatechange = () => {
|
|
if (pc !== peer) return
|
|
diagnostics.iceConnection = peer.iceConnectionState
|
|
record(diagnostics.iceConnection === 'failed' ? 'error' : 'info', `ICE: ${diagnostics.iceConnection}`)
|
|
}
|
|
peer.onsignalingstatechange = () => { if (pc === peer) diagnostics.signaling = peer.signalingState }
|
|
if (role === 'publisher') {
|
|
localStream?.getTracks().forEach((track) => peer.addTrack(track, localStream))
|
|
} else {
|
|
const incoming = new MediaStream()
|
|
remoteStream.value = incoming
|
|
peer.ontrack = (event) => {
|
|
const source = event.streams?.[0]
|
|
if (source) source.getTracks().forEach((track) => { if (!incoming.getTrackById(track.id)) incoming.addTrack(track) })
|
|
else incoming.addTrack(event.track)
|
|
diagnostics.remoteTracks = incoming.getTracks().length
|
|
record('success', `收到${event.track.kind === 'video' ? '视频' : '音频'}轨道`, event.track.id)
|
|
}
|
|
peer.addTransceiver('video', { direction: 'recvonly' })
|
|
peer.addTransceiver('audio', { direction: 'recvonly' })
|
|
}
|
|
const offer = await peer.createOffer()
|
|
await peer.setLocalDescription(offer)
|
|
await waitForIce(peer)
|
|
record('info', '本地 SDP 与 ICE 候选已就绪')
|
|
send('Offer', { type: peer.localDescription.type, sdp: peer.localDescription.sdp })
|
|
}
|
|
|
|
const handleMessage = async (event) => {
|
|
const packet = JSON.parse(event.data)
|
|
const payload = packet.payload || {}
|
|
switch (packet.type) {
|
|
case 'Joined':
|
|
record('success', 'WebSocket 加入直播间成功', `${role}:${streamId}`)
|
|
state.value = 'negotiating'
|
|
message.value = role === 'publisher' ? '正在建立推流连接' : '正在连接直播画面'
|
|
await negotiate()
|
|
connectResolve?.()
|
|
connectResolve = null
|
|
connectReject = null
|
|
break
|
|
case 'Answer':
|
|
if (pc) await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp })
|
|
record('success', '服务端 SDP 应答已应用')
|
|
break
|
|
case 'PublisherStatus':
|
|
diagnostics.publisherReady = Boolean(payload.ready)
|
|
record(payload.ready ? 'success' : 'info', payload.ready ? '主播媒体轨道已就绪' : '主播媒体轨道未就绪', payload.reason || '')
|
|
if (role === 'subscriber' && !payload.ready) { state.value = 'waiting'; message.value = '主播暂未开播' }
|
|
break
|
|
case 'Renegotiate':
|
|
if (role === 'subscriber') { record('info', '主播轨道变化,重新协商拉流'); await negotiate() }
|
|
break
|
|
case 'NewSubscriber':
|
|
viewerCount.value += 1
|
|
break
|
|
case 'SubscriberLeft':
|
|
viewerCount.value = Math.max(0, viewerCount.value - 1)
|
|
break
|
|
case 'ChatHistory':
|
|
chatMessages.value = (payload.messages || []).slice(-100)
|
|
break
|
|
case 'ChatMessage':
|
|
case 'GiftMessage':
|
|
chatMessages.value = [...chatMessages.value.slice(-99), payload]
|
|
break
|
|
case 'HeartbeatAck':
|
|
diagnostics.lastHeartbeatAt = new Date().toISOString()
|
|
break
|
|
case 'Kicked':
|
|
state.value = 'kicked'
|
|
message.value = '账号已在其他位置进入该直播间'
|
|
diagnostics.lastError = message.value
|
|
record('error', message.value)
|
|
disconnect()
|
|
break
|
|
case 'JoinRejected':
|
|
connectReject?.(new Error(payload.message || '直播连接被拒绝'))
|
|
connectResolve = null
|
|
connectReject = null
|
|
state.value = 'error'
|
|
message.value = payload.message || '直播连接被拒绝'
|
|
diagnostics.lastError = message.value
|
|
record('error', message.value, payload.code || '')
|
|
break
|
|
case 'Error':
|
|
state.value = 'error'
|
|
message.value = payload.message || '直播连接失败'
|
|
diagnostics.lastError = message.value
|
|
record('error', message.value, payload.code || '')
|
|
break
|
|
}
|
|
}
|
|
|
|
const connect = ({ mode, id, media }) => new Promise((resolve, reject) => {
|
|
if (!userStore.token) {
|
|
state.value = 'auth-required'
|
|
message.value = '登录后才能进入实时直播'
|
|
reject(new Error(message.value))
|
|
return
|
|
}
|
|
resetDiagnostics()
|
|
role = mode
|
|
streamId = id
|
|
localStream = media || null
|
|
state.value = 'connecting'
|
|
message.value = '正在连接直播服务'
|
|
connectResolve = resolve
|
|
connectReject = reject
|
|
socket = new WebSocket(websocketURL(userStore.token))
|
|
diagnostics.websocket = 'connecting'
|
|
record('info', '正在连接直播 WebSocket')
|
|
socket.onopen = () => {
|
|
diagnostics.websocket = 'open'
|
|
record('success', '直播 WebSocket 已连接')
|
|
send('Join', { role, streamId })
|
|
heartbeat = window.setInterval(() => send('Heartbeat', { streamId }), 15000)
|
|
}
|
|
socket.onmessage = (event) => handleMessage(event).catch((error) => {
|
|
state.value = 'error'
|
|
message.value = error.message
|
|
diagnostics.lastError = error.message
|
|
record('error', error.message)
|
|
})
|
|
socket.onerror = () => {
|
|
diagnostics.websocket = 'error'
|
|
diagnostics.lastError = '无法连接直播服务'
|
|
record('error', diagnostics.lastError)
|
|
state.value = 'error'
|
|
message.value = diagnostics.lastError
|
|
reject(new Error(message.value))
|
|
}
|
|
socket.onclose = (event) => {
|
|
diagnostics.websocket = 'closed'
|
|
record(event.wasClean ? 'info' : 'error', '直播 WebSocket 已关闭', `${event.code}`)
|
|
window.clearInterval(heartbeat)
|
|
connectReject?.(new Error('直播 WebSocket 在加入房间前关闭'))
|
|
connectResolve = null
|
|
connectReject = null
|
|
if (!['idle', 'kicked'].includes(state.value)) {
|
|
state.value = 'closed'
|
|
message.value = '直播连接已关闭'
|
|
}
|
|
}
|
|
})
|
|
|
|
const disconnect = () => {
|
|
window.clearInterval(heartbeat)
|
|
send('Leave', { streamId })
|
|
socket?.close()
|
|
socket = null
|
|
connectResolve = null
|
|
connectReject = null
|
|
closePeer()
|
|
if (state.value !== 'kicked') state.value = 'idle'
|
|
}
|
|
|
|
const sendChat = (content) => {
|
|
const value = String(content || '').trim()
|
|
if (!value || socket?.readyState !== WebSocket.OPEN) return false
|
|
send('ChatSend', { content: value })
|
|
return true
|
|
}
|
|
|
|
onBeforeUnmount(disconnect)
|
|
return { state, message, viewerCount, remoteStream, chatMessages, events, diagnostics, connect, disconnect, sendChat, collectStats }
|
|
}
|