Skip to content

Commit

Permalink
fix(webcam): fix some connection issues in Camera-Streamer (#1981)
Browse files Browse the repository at this point in the history
  • Loading branch information
meteyou committed Sep 15, 2024
1 parent 2a49060 commit f4ee321
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 112 deletions.
2 changes: 1 addition & 1 deletion src/components/webcams/WebcamWrapperItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<j-muxer-stream-async :cam-settings="webcam" :printer-url="printerUrl" />
</template>
<template v-else-if="service === 'webrtc-camerastreamer'">
<webrtc-camera-streamer-async :cam-settings="webcam" :printer-url="printerUrl" />
<webrtc-camera-streamer-async :cam-settings="webcam" :printer-url="printerUrl" :page="page" />
</template>
<template v-else-if="service === 'webrtc-janus'">
<janus-streamer-async :cam-settings="webcam" :printer-url="printerUrl" />
Expand Down
277 changes: 166 additions & 111 deletions src/components/webcams/streamers/WebrtcCameraStreamer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,22 @@ import BaseMixin from '@/components/mixins/base'
import { GuiWebcamStateWebcam } from '@/store/gui/webcams/types'
import WebcamMixin from '@/components/mixins/webcam'
interface CameraStreamerResponse extends RTCSessionDescriptionInit {
id: string
iceServers?: RTCIceServer[]
}
@Component
export default class WebrtcCameraStreamer extends Mixins(BaseMixin, WebcamMixin) {
private pc: RTCPeerConnection | null = null
private useStun = false
private remote_pc_id: string | null = null
private aspectRatio: null | number = null
private status: string = 'connecting'
private restartTimer: number | null = null
pc: RTCPeerConnection | null = null
useStun = false
aspectRatio: null | number = null
status: string = 'connecting'
restartTimer: number | null = null
@Prop({ required: true }) readonly camSettings!: GuiWebcamStateWebcam
@Prop({ default: null }) declare readonly printerUrl: string | null
@Prop({ type: String, default: null }) readonly page!: string | null
@Ref() declare stream: HTMLVideoElement
get url() {
Expand All @@ -55,128 +60,178 @@ export default class WebrtcCameraStreamer extends Mixins(BaseMixin, WebcamMixin)
return output
}
startStream() {
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1
const requestIceServers = this.useStun ? [{ urls: ['stun:stun.l.google.com:19302'] }] : null
// This WebRTC signaling pattern is designed for camera-streamer, a common webcam server the supports WebRTC.
fetch(this.url, {
body: JSON.stringify({
type: 'request',
iceServers: requestIceServers,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
.then((response) => response.json())
.then((answer) => {
let peerConnectionConfig: any = {
sdpSemantics: 'unified-plan',
}
// It's important to set any ICE servers returned, which could include servers we requested or servers
// setup by the server. But note that older versions of camera-streamer won't return this property.
if (answer.iceServers) {
peerConnectionConfig.iceServers = answer.iceServers
}
this.pc = new RTCPeerConnection(peerConnectionConfig)
this.pc.addTransceiver('video', { direction: 'recvonly' })
this.pc.addEventListener(
'track',
(evt) => {
if (evt.track.kind == 'video' && this.$refs.stream) {
// @ts-ignore
this.$refs.stream.srcObject = evt.streams[0]
}
},
false
)
this.pc.addEventListener('connectionstatechange', () => {
this.status = (this.pc?.connectionState ?? '').toString()
// clear restartTimer if it is set
if (this.restartTimer) window.clearTimeout(this.restartTimer)
if (['failed', 'disconnected'].includes(this.status)) {
// set restartTimer to restart stream after 5 seconds
this.restartTimer = window.setTimeout(() => {
this.restartStream()
}, 5000)
}
})
this.pc.addEventListener('icecandidate', (e) => {
if (e.candidate) {
return fetch(this.url, {
body: JSON.stringify({
type: 'remote_candidate',
id: this.remote_pc_id,
candidates: [e.candidate],
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
}).catch(function (error) {
window.console.error(error)
})
}
})
this.remote_pc_id = answer.id
return this.pc?.setRemoteDescription(answer)
get expanded(): boolean {
if (this.page !== 'dashboard') return true
return this.$store.getters['gui/getPanelExpand']('webcam-panel', this.viewport) ?? false
}
// start or stop the video when the expanded state changes
@Watch('expanded', { immediate: true })
expandChanged(newExpanded: boolean): void {
if (!newExpanded) {
this.terminate()
return
}
this.start()
}
// This WebRTC signaling pattern is designed for camera-streamer, a common webcam server the supports WebRTC.
async start() {
if (this.restartTimer) {
this.log('Clearing restart timer before starting stream')
window.clearTimeout(this.restartTimer)
}
if (!this.expanded) {
this.log('Not expanded, not starting stream')
return
}
this.log(`Requesting ICE servers from ${this.url}`)
try {
const requestIceServers = this.useStun ? [{ urls: ['stun:stun.l.google.com:19302'] }] : null
const response = await fetch(this.url, {
body: JSON.stringify({ type: 'request', iceServers: requestIceServers }),
method: 'POST',
})
.then(() => this.pc?.createAnswer())
.then((answer) => this.pc?.setLocalDescription(answer))
.then(() => {
const offer = this.pc?.localDescription
return fetch(this.url, {
body: JSON.stringify({
type: offer?.type,
id: this.remote_pc_id,
sdp: offer?.sdp,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (response.status !== 200) {
this.log(`Failed to start stream: ${response.status}`)
this.restartStream()
return
}
const answer = await response.json()
await this.onIceServers(answer)
} catch (e) {
this.log('Failed to start stream', e)
}
}
async onIceServers(iceResponse: CameraStreamerResponse) {
if (this.pc) this.pc.close()
// It's important to set any ICE servers returned, which could include servers we requested or servers
// setup by the server. But note that older versions of camera-streamer won't return this property.
let peerConnectionConfig: RTCConfiguration = {
iceServers: iceResponse.iceServers ?? [],
// https://webrtc.org/getting-started/unified-plan-transition-guide
// @ts-ignore
sdpSemantics: 'unified-plan',
}
this.pc = new RTCPeerConnection(peerConnectionConfig)
this.pc.addTransceiver('video', { direction: 'recvonly' })
this.pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => this.onIceCandidate(e, iceResponse.id)
this.pc.onconnectionstatechange = () => this.onConnectionStateChange()
this.pc.ontrack = (e) => this.onTrack(e)
await this.pc?.setRemoteDescription(iceResponse)
const answer = await this.pc.createAnswer()
await this.pc.setLocalDescription(answer)
const offer = this.pc.localDescription
if (!offer) {
this.log('Failed to create offer')
this.restartStream()
return
}
try {
const response = await fetch(this.url, {
body: JSON.stringify({
type: offer?.type,
id: iceResponse.id,
sdp: offer?.sdp,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
.then((response: any) => {
if (isFirefox) this.status = 'connected'
return response.json()
if (response.status !== 200) {
this.log(`Failed to send offer: ${response.status}`)
this.restartStream()
}
} catch (e) {
this.log('Failed to send offer', e)
this.restartStream()
}
}
async onIceCandidate(e: RTCPeerConnectionIceEvent, id: string) {
if (!e.candidate) return
try {
const response = await fetch(this.url, {
body: JSON.stringify({
id,
type: 'remote_candidate',
candidates: [e.candidate],
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
.catch((e) => {
window.console.error(e)
if (response.status !== 200) {
this.log(`Failed to send ICE candidate: ${response.status}`)
this.restartStream()
}
} catch (e) {
this.log('Failed to send ICE candidate', e)
this.restartStream()
}
}
// clear restartTimer if it is set
if (this.restartTimer) window.clearTimeout(this.restartTimer)
onConnectionStateChange() {
this.status = this.pc?.connectionState ?? 'connecting'
// set restartTimer to restart stream after 5 seconds
this.restartTimer = window.setTimeout(() => {
this.restartStream()
}, 5000)
})
this.log(`State: ${this.status}`)
if (['failed', 'disconnected'].includes(this.status)) {
this.restartStream(5000)
}
}
mounted() {
this.startStream()
onTrack(e: RTCTrackEvent) {
if (e.track.kind !== 'video') return
this.stream.srcObject = e.streams[0]
}
log(msg: string, obj?: any) {
const message = `[WebRTC camera-streamer] ${msg}`
if (obj) {
window.console.log(message, obj)
return
}
window.console.log(message)
}
beforeDestroy() {
this.pc?.close()
this.terminate()
if (this.restartTimer) window.clearTimeout(this.restartTimer)
}
restartStream() {
terminate() {
this.log('Terminating stream')
this.pc?.close()
setTimeout(async () => {
this.startStream()
}, 500)
}
restartStream(delay = 500) {
this.terminate()
if (this.restartTimer) return
this.restartTimer = window.setTimeout(async () => {
this.restartTimer = null
await this.start()
}, delay)
}
@Watch('url')
async changedUrl() {
changedUrl() {
this.restartStream()
}
}
Expand Down

0 comments on commit f4ee321

Please sign in to comment.