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