You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

240 lines
6.4 KiB
Vue

<template>
<div class="webrtc-wrapper">
<video
ref="videoRef"
autoplay
muted
playsinline
class="video-player"
></video>
<div v-if="loading" class="loading-overlay">
<span class="spinner"></span>
<span>{{ loadingText }}</span>
</div>
<div v-if="error" class="error-overlay">{{ error }}</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
src: { type: String, required: true }
})
const emit = defineEmits(['connection-status'])
const videoRef = ref(null)
const error = ref('')
const loading = ref(false)
const loadingText = ref('连接中...')
let pc = null
let sessionUrl = ''
let queuedCandidates = []
let offerData = null
let retryTimer = null
let retryCount = 0
const MAX_RETRIES = 30
const RETRY_INTERVAL = 2000
let destroyed = false
const whepUrl = () => {
const base = props.src.replace(/\/+$/, '')
return base + '/whep'
}
function parseOffer(sdp) {
const ret = { iceUfrag: '', icePwd: '', medias: [] }
for (const line of sdp.split('\r\n')) {
if (line.startsWith('m=')) ret.medias.push(line.slice('m='.length))
else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) ret.iceUfrag = line.slice('a=ice-ufrag:'.length)
else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) ret.icePwd = line.slice('a=ice-pwd:'.length)
}
return ret
}
function generateSdpFragment(od, candidates) {
const byMedia = {}
for (const c of candidates) {
const mid = c.sdpMLineIndex
if (!byMedia[mid]) byMedia[mid] = []
byMedia[mid].push(c)
}
let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n' + 'a=ice-pwd:' + od.icePwd + '\r\n'
let mid = 0
for (const media of od.medias) {
if (byMedia[mid]) {
frag += 'm=' + media + '\r\n' + 'a=mid:' + mid + '\r\n'
for (const c of byMedia[mid]) frag += 'a=' + c.candidate + '\r\n'
}
mid++
}
return frag
}
function linkToIceServers(links) {
if (!links) return []
return links.split(', ').map((link) => {
const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i)
const ret = { urls: [m[1]] }
if (m[3] !== undefined) {
ret.username = JSON.parse(`"${m[3]}"`)
ret.credential = JSON.parse(`"${m[4]}"`)
ret.credentialType = 'password'
}
return ret
})
}
function sendLocalCandidates(candidates) {
fetch(sessionUrl, {
method: 'PATCH',
headers: { 'Content-Type': 'application/trickle-ice-sdpfrag', 'If-Match': '*' },
body: generateSdpFragment(offerData, candidates)
}).catch(() => {})
}
function cleanupConnection() {
if (pc) { pc.close(); pc = null }
if (videoRef.value) videoRef.value.srcObject = null
sessionUrl = ''
queuedCandidates = []
offerData = null
}
function cancelRetry() {
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
async function connect() {
if (!props.src || destroyed) return
cancelRetry()
cleanupConnection()
loading.value = true
loadingText.value = retryCount > 0 ? `连接中... (第 ${retryCount} 次重试)` : '连接中...'
error.value = ''
try {
const optionsRes = await fetch(whepUrl(), { method: 'OPTIONS' })
const iceServers = linkToIceServers(optionsRes.headers.get('Link'))
pc = new RTCPeerConnection({ iceServers, sdpSemantics: 'unified-plan' })
pc.addTransceiver('video', { direction: 'sendrecv' })
pc.addTransceiver('audio', { direction: 'sendrecv' })
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0]
emit('connection-status', true)
retryCount = 0
loading.value = false
error.value = ''
}
}
pc.onicecandidate = (evt) => {
if (evt.candidate !== null) {
if (sessionUrl) sendLocalCandidates([evt.candidate])
else queuedCandidates.push(evt.candidate)
}
}
const offer = await pc.createOffer()
offerData = parseOffer(offer.sdp)
await pc.setLocalDescription(offer)
const res = await fetch(whepUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
})
if (!res.ok) {
const errBody = await res.text().catch(() => '')
const isNotPublishing = errBody.includes('no one is publishing')
if (isNotPublishing) throw new Error('NOT_PUBLISHING')
throw new Error(errBody || `HTTP ${res.status}`)
}
const location = res.headers.get('location')
if (location) sessionUrl = new URL(location, whepUrl()).toString()
const answer = await res.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
if (queuedCandidates.length > 0 && sessionUrl) {
sendLocalCandidates(queuedCandidates)
queuedCandidates = []
}
retryCount = 0
} catch (e) {
const isNotPublishing = e.message === 'NOT_PUBLISHING'
cleanupConnection()
if (isNotPublishing && retryCount < MAX_RETRIES && !destroyed) {
retryCount++
loadingText.value = `等待推流中... (${retryCount}/${MAX_RETRIES})`
retryTimer = setTimeout(() => connect(), RETRY_INTERVAL)
} else {
error.value = isNotPublishing ? '视频流尚未就绪,请稍后重试' : '连接失败,请检查视频地址'
emit('connection-status', false)
loading.value = false
console.error('[WebRTC]', e.message === 'NOT_PUBLISHING' ? 'stream not publishing' : e)
}
}
}
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc && newSrc !== oldSrc) {
retryCount = 0
cancelRetry()
connect()
}
})
onMounted(() => connect())
onUnmounted(() => {
destroyed = true
cancelRetry()
cleanupConnection()
})
</script>
<style scoped>
.webrtc-wrapper { width: 100%; height: 100%; position: relative; background: #000; }
.video-player { width: 100%; height: 100%; object-fit: contain; }
.error-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #409eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>