|
@@ -10,7 +10,25 @@
|
|
|
/>
|
|
/>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
- <!-- WebRTC Player -->
|
|
|
|
|
|
|
+ <!-- Cloudflare Stream WebRTC (WHEP) Player -->
|
|
|
|
|
+ <template v-else-if="playerType === 'cloudflare-webrtc'">
|
|
|
|
|
+ <video
|
|
|
|
|
+ ref="videoRef"
|
|
|
|
|
+ class="video-element"
|
|
|
|
|
+ :controls="controls"
|
|
|
|
|
+ :autoplay="autoplay"
|
|
|
|
|
+ :muted="muted"
|
|
|
|
|
+ playsinline
|
|
|
|
|
+ />
|
|
|
|
|
+ <!-- WebRTC 连接状态 -->
|
|
|
|
|
+ <div v-if="cfWebrtcStatus !== 'connected'" class="webrtc-status">
|
|
|
|
|
+ <span v-if="cfWebrtcStatus === 'connecting'" class="status-connecting">WebRTC 连接中...</span>
|
|
|
|
|
+ <span v-else-if="cfWebrtcStatus === 'failed'" class="status-failed">WebRTC 连接失败</span>
|
|
|
|
|
+ <span v-else class="status-idle">等待连接</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- WebRTC Player (go2rtc) -->
|
|
|
<template v-else-if="playerType === 'webrtc'">
|
|
<template v-else-if="playerType === 'webrtc'">
|
|
|
<video
|
|
<video
|
|
|
ref="videoRef"
|
|
ref="videoRef"
|
|
@@ -66,7 +84,7 @@ interface Props {
|
|
|
src?: string // 视频源地址
|
|
src?: string // 视频源地址
|
|
|
videoId?: string // Cloudflare Stream video ID
|
|
videoId?: string // Cloudflare Stream video ID
|
|
|
customerDomain?: string // Cloudflare 自定义域名
|
|
customerDomain?: string // Cloudflare 自定义域名
|
|
|
- playerType?: 'cloudflare' | 'hls' | 'native' | 'webrtc'
|
|
|
|
|
|
|
+ playerType?: 'cloudflare' | 'cloudflare-webrtc' | 'hls' | 'native' | 'webrtc'
|
|
|
useIframe?: boolean // Cloudflare 是否使用 iframe
|
|
useIframe?: boolean // Cloudflare 是否使用 iframe
|
|
|
controls?: boolean
|
|
controls?: boolean
|
|
|
autoplay?: boolean
|
|
autoplay?: boolean
|
|
@@ -74,7 +92,7 @@ interface Props {
|
|
|
loop?: boolean
|
|
loop?: boolean
|
|
|
poster?: string
|
|
poster?: string
|
|
|
preload?: 'auto' | 'metadata' | 'none'
|
|
preload?: 'auto' | 'metadata' | 'none'
|
|
|
- // WebRTC 配置
|
|
|
|
|
|
|
+ // WebRTC 配置 (go2rtc)
|
|
|
go2rtcUrl?: string // go2rtc 服务地址,如 http://localhost:1984
|
|
go2rtcUrl?: string // go2rtc 服务地址,如 http://localhost:1984
|
|
|
streamName?: string // 流名称,如 camera1
|
|
streamName?: string // 流名称,如 camera1
|
|
|
}
|
|
}
|
|
@@ -102,7 +120,9 @@ const videoRef = ref<HTMLVideoElement>()
|
|
|
|
|
|
|
|
let hlsInstance: any = null
|
|
let hlsInstance: any = null
|
|
|
let peerConnection: RTCPeerConnection | null = null
|
|
let peerConnection: RTCPeerConnection | null = null
|
|
|
|
|
+let cfPeerConnection: RTCPeerConnection | null = null
|
|
|
const webrtcStatus = ref<'idle' | 'connecting' | 'connected' | 'failed'>('idle')
|
|
const webrtcStatus = ref<'idle' | 'connecting' | 'connected' | 'failed'>('idle')
|
|
|
|
|
+const cfWebrtcStatus = ref<'idle' | 'connecting' | 'connected' | 'failed'>('idle')
|
|
|
|
|
|
|
|
// Cloudflare Stream iframe URL
|
|
// Cloudflare Stream iframe URL
|
|
|
const cloudflareIframeSrc = computed(() => {
|
|
const cloudflareIframeSrc = computed(() => {
|
|
@@ -117,6 +137,13 @@ const cloudflareIframeSrc = computed(() => {
|
|
|
return `https://${domain}/${props.videoId}/iframe${queryString ? `?${queryString}` : ''}`
|
|
return `https://${domain}/${props.videoId}/iframe${queryString ? `?${queryString}` : ''}`
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+// Cloudflare Stream WebRTC (WHEP) URL
|
|
|
|
|
+const cloudflareWebRTCUrl = computed(() => {
|
|
|
|
|
+ if (!props.videoId) return ''
|
|
|
|
|
+ const domain = props.customerDomain || 'customer-xxx.cloudflarestream.com'
|
|
|
|
|
+ return `https://${domain}/${props.videoId}/webRTC/play`
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
// 初始化 HLS.js
|
|
// 初始化 HLS.js
|
|
|
async function initHls() {
|
|
async function initHls() {
|
|
|
if (!videoRef.value || !props.src) return
|
|
if (!videoRef.value || !props.src) return
|
|
@@ -187,7 +214,114 @@ function destroyHls() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 初始化 WebRTC
|
|
|
|
|
|
|
+// 初始化 Cloudflare WebRTC (WHEP)
|
|
|
|
|
+async function initCloudflareWebRTC() {
|
|
|
|
|
+ if (!videoRef.value || !props.videoId) return
|
|
|
|
|
+
|
|
|
|
|
+ cfWebrtcStatus.value = 'connecting'
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const whepUrl = cloudflareWebRTCUrl.value
|
|
|
|
|
+ console.log('Cloudflare WebRTC URL:', whepUrl)
|
|
|
|
|
+
|
|
|
|
|
+ // 创建 RTCPeerConnection
|
|
|
|
|
+ cfPeerConnection = new RTCPeerConnection({
|
|
|
|
|
+ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }, { urls: 'stun:stun.l.google.com:19302' }],
|
|
|
|
|
+ bundlePolicy: 'max-bundle'
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 监听远程流
|
|
|
|
|
+ cfPeerConnection.ontrack = (event) => {
|
|
|
|
|
+ console.log('Cloudflare WebRTC: received track', event.track.kind)
|
|
|
|
|
+ if (videoRef.value && event.streams[0]) {
|
|
|
|
|
+ videoRef.value.srcObject = event.streams[0]
|
|
|
|
|
+ cfWebrtcStatus.value = 'connected'
|
|
|
|
|
+ if (props.autoplay) {
|
|
|
|
|
+ videoRef.value.play().catch((e) => console.warn('Autoplay failed:', e))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 监听连接状态
|
|
|
|
|
+ cfPeerConnection.oniceconnectionstatechange = () => {
|
|
|
|
|
+ if (!cfPeerConnection) return
|
|
|
|
|
+ const state = cfPeerConnection.iceConnectionState
|
|
|
|
|
+ console.log('Cloudflare WebRTC ICE state:', state)
|
|
|
|
|
+ if (state === 'connected' || state === 'completed') {
|
|
|
|
|
+ cfWebrtcStatus.value = 'connected'
|
|
|
|
|
+ } else if (state === 'failed' || state === 'disconnected') {
|
|
|
|
|
+ cfWebrtcStatus.value = 'failed'
|
|
|
|
|
+ emit('error', { type: 'cloudflare-webrtc', message: `ICE connection ${state}` })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 添加音视频收发器
|
|
|
|
|
+ cfPeerConnection.addTransceiver('video', { direction: 'recvonly' })
|
|
|
|
|
+ cfPeerConnection.addTransceiver('audio', { direction: 'recvonly' })
|
|
|
|
|
+
|
|
|
|
|
+ // 创建 Offer
|
|
|
|
|
+ const offer = await cfPeerConnection.createOffer()
|
|
|
|
|
+ await cfPeerConnection.setLocalDescription(offer)
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 ICE gathering 完成或超时
|
|
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
|
|
+ if (cfPeerConnection?.iceGatheringState === 'complete') {
|
|
|
|
|
+ resolve()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const timeout = setTimeout(resolve, 2000)
|
|
|
|
|
+ cfPeerConnection!.onicegatheringstatechange = () => {
|
|
|
|
|
+ if (cfPeerConnection?.iceGatheringState === 'complete') {
|
|
|
|
|
+ clearTimeout(timeout)
|
|
|
|
|
+ resolve()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 发送 Offer 到 Cloudflare WHEP 端点
|
|
|
|
|
+ const response = await fetch(whepUrl, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Content-Type': 'application/sdp'
|
|
|
|
|
+ },
|
|
|
|
|
+ body: cfPeerConnection.localDescription?.sdp
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const errorText = await response.text()
|
|
|
|
|
+ throw new Error(`WHEP request failed: ${response.status} - ${errorText}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取 Answer SDP
|
|
|
|
|
+ const answerSdp = await response.text()
|
|
|
|
|
+ console.log('Cloudflare WebRTC: received answer')
|
|
|
|
|
+
|
|
|
|
|
+ await cfPeerConnection.setRemoteDescription({
|
|
|
|
|
+ type: 'answer',
|
|
|
|
|
+ sdp: answerSdp
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 绑定视频事件
|
|
|
|
|
+ bindVideoEvents()
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('Cloudflare WebRTC 连接失败:', error)
|
|
|
|
|
+ cfWebrtcStatus.value = 'failed'
|
|
|
|
|
+ emit('error', { type: 'cloudflare-webrtc', message: String(error) })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function destroyCloudflareWebRTC() {
|
|
|
|
|
+ if (cfPeerConnection) {
|
|
|
|
|
+ cfPeerConnection.close()
|
|
|
|
|
+ cfPeerConnection = null
|
|
|
|
|
+ }
|
|
|
|
|
+ cfWebrtcStatus.value = 'idle'
|
|
|
|
|
+ if (videoRef.value) {
|
|
|
|
|
+ videoRef.value.srcObject = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 初始化 WebRTC (go2rtc)
|
|
|
async function initWebRTC() {
|
|
async function initWebRTC() {
|
|
|
if (!videoRef.value || !props.go2rtcUrl || !props.streamName) return
|
|
if (!videoRef.value || !props.go2rtcUrl || !props.streamName) return
|
|
|
|
|
|
|
@@ -325,6 +459,9 @@ function reconnect() {
|
|
|
if (props.playerType === 'webrtc') {
|
|
if (props.playerType === 'webrtc') {
|
|
|
destroyWebRTC()
|
|
destroyWebRTC()
|
|
|
initWebRTC()
|
|
initWebRTC()
|
|
|
|
|
+ } else if (props.playerType === 'cloudflare-webrtc') {
|
|
|
|
|
+ destroyCloudflareWebRTC()
|
|
|
|
|
+ initCloudflareWebRTC()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -337,7 +474,8 @@ defineExpose({
|
|
|
screenshot,
|
|
screenshot,
|
|
|
fullscreen,
|
|
fullscreen,
|
|
|
reconnect,
|
|
reconnect,
|
|
|
- webrtcStatus
|
|
|
|
|
|
|
+ webrtcStatus,
|
|
|
|
|
+ cfWebrtcStatus
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
watch(
|
|
watch(
|
|
@@ -358,11 +496,24 @@ watch([() => props.go2rtcUrl, () => props.streamName], ([newUrl, newStream]) =>
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+// Cloudflare WebRTC 配置变化时重新连接
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => props.videoId,
|
|
|
|
|
+ (newVideoId) => {
|
|
|
|
|
+ if (props.playerType === 'cloudflare-webrtc' && newVideoId) {
|
|
|
|
|
+ destroyCloudflareWebRTC()
|
|
|
|
|
+ initCloudflareWebRTC()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
if (props.playerType === 'hls') {
|
|
if (props.playerType === 'hls') {
|
|
|
initHls()
|
|
initHls()
|
|
|
} else if (props.playerType === 'webrtc') {
|
|
} else if (props.playerType === 'webrtc') {
|
|
|
initWebRTC()
|
|
initWebRTC()
|
|
|
|
|
+ } else if (props.playerType === 'cloudflare-webrtc') {
|
|
|
|
|
+ initCloudflareWebRTC()
|
|
|
} else if (props.playerType === 'native') {
|
|
} else if (props.playerType === 'native') {
|
|
|
bindVideoEvents()
|
|
bindVideoEvents()
|
|
|
}
|
|
}
|
|
@@ -371,6 +522,7 @@ onMounted(() => {
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
destroyHls()
|
|
destroyHls()
|
|
|
destroyWebRTC()
|
|
destroyWebRTC()
|
|
|
|
|
+ destroyCloudflareWebRTC()
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|