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 } }