Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(webcam): add support for go2rtc webrtc #1651

Merged
merged 11 commits into from
Dec 10, 2023
31 changes: 31 additions & 0 deletions src/components/settings/Webcams/WebcamForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@
:label="$t('Settings.WebcamsTab.HideFps')" />
</v-col>
</v-row>
<v-row v-if="hasAudioOption">
<v-col class="pt-1 pb-3">
<v-checkbox
v-model="enableAudio"
class="mt-1"
hide-details
:label="$t('Settings.WebcamsTab.EnableAudio')" />
</v-col>
</v-row>
<v-row>
<v-col class="pt-1 pb-3">
<div class="v-label v-label--active theme--dark text-subtitle-1">
Expand Down Expand Up @@ -231,6 +240,7 @@ export default class WebcamForm extends Mixins(BaseMixin, WebcamMixin) {
{ value: 'uv4l-mjpeg', text: this.$t('Settings.WebcamsTab.Uv4lMjpeg') },
{ value: 'ipstream', text: this.$t('Settings.WebcamsTab.Ipstream') },
{ value: 'webrtc-camerastreamer', text: this.$t('Settings.WebcamsTab.WebrtcCameraStreamer') },
{ value: 'webrtc-go2rtc', text: this.$t('Settings.WebcamsTab.WebrtcGo2rtc') },
{ value: 'webrtc-mediamtx', text: this.$t('Settings.WebcamsTab.WebrtcMediaMTX') },
{ value: 'hlsstream', text: this.$t('Settings.WebcamsTab.Hlsstream') },
{ value: 'jmuxer-stream', text: this.$t('Settings.WebcamsTab.JMuxerStream') },
Expand Down Expand Up @@ -263,6 +273,10 @@ export default class WebcamForm extends Mixins(BaseMixin, WebcamMixin) {
return ['mjpegstreamer', 'mjpegstreamer-adaptive'].includes(this.webcam.service)
}
get hasAudioOption() {
return ['webrtc-go2rtc'].includes(this.webcam.service)
}
get hideFps() {
return this.webcam.extra_data?.hideFps ?? false
}
Expand All @@ -280,6 +294,23 @@ export default class WebcamForm extends Mixins(BaseMixin, WebcamMixin) {
this.webcam.extra_data.hideFps = newVal
}
get enableAudio() {
return this.webcam.extra_data?.enableAudio ?? false
}
set enableAudio(newVal) {
if (!('extra_data' in this.webcam)) {
this.webcam.extra_data = {
enableAudio: newVal,
}
return
}
// @ts-ignore
this.webcam.extra_data.enableAudio = newVal
}
mounted() {
this.oldWebcamName = this.webcam.name
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/webcams/WebcamWrapperItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<template v-else-if="service === 'webrtc-mediamtx'">
<webrtc-media-m-t-x-async :cam-settings="webcam" :printer-url="printerUrl" />
</template>
<template v-else-if="service === 'webrtc-go2rtc'">
<webrtc-go2rtc-async :cam-settings="webcam" :printer-url="printerUrl" />
</template>
<template v-else>
<p class="text-center py-3 font-italic">{{ $t('Panels.WebcamPanel.UnknownWebcamService') }}</p>
</template>
Expand All @@ -51,6 +54,7 @@ import { DynamicCamLoader } from '@/components/webcams/streamers/DynamicCamLoade
Uv4lMjpegAsync: DynamicCamLoader('Uv4lMjpeg'),
WebrtcCameraStreamerAsync: DynamicCamLoader('WebrtcCameraStreamer'),
WebrtcMediaMTXAsync: DynamicCamLoader('WebrtcMediaMTX'),
WebrtcGo2rtcAsync: DynamicCamLoader('WebrtcGo2rtc'),
},
})
export default class WebcamWrapperItem extends Mixins(BaseMixin) {
Expand Down
3 changes: 3 additions & 0 deletions src/components/webcams/streamers/DynamicCamLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type StreamerTypes =
| 'Uv4lMjpeg'
| 'WebrtcCameraStreamer'
| 'WebrtcMediaMTX'
| 'WebrtcGo2rtc'

function getDynamicCamImport(componentName: StreamerTypes) {
// split each webcam streamer into its own chunk
Expand All @@ -32,6 +33,8 @@ function getDynamicCamImport(componentName: StreamerTypes) {
return () => import('@/components/webcams/streamers/WebrtcCameraStreamer.vue')
case 'WebrtcMediaMTX':
return () => import('@/components/webcams/streamers/WebrtcMediaMTX.vue')
case 'WebrtcGo2rtc':
return () => import('@/components/webcams/streamers/WebrtcGo2rtc.vue')
}
}

Expand Down
247 changes: 247 additions & 0 deletions src/components/webcams/streamers/WebrtcGo2rtc.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<template>
<div>
<video
v-show="status === 'connected'"
ref="video"
:style="webcamStyle"
class="webcamImage"
autoplay
playsinline
muted />
<v-row v-if="status !== 'connected'">
<v-col class="_webcam_webrtc_output text-center d-flex flex-column justify-center align-center">
<v-progress-circular v-if="status === 'connecting'" indeterminate color="primary" class="mb-3" />
<span class="mt-3">{{ status }}</span>
</v-col>
</v-row>
</div>
</template>

<script lang="ts">
import { Component, Mixins, Prop, Ref, Watch } from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base'
import { GuiWebcamStateWebcam } from '@/store/gui/webcams/types'
import WebcamMixin from '@/components/mixins/webcam'
@Component
export default class WebrtcGo2rtc extends Mixins(BaseMixin, WebcamMixin) {
@Prop({ required: true }) readonly camSettings!: GuiWebcamStateWebcam
@Prop({ default: null }) readonly printerUrl!: string | null
@Ref() declare video: HTMLVideoElement
pc: RTCPeerConnection | null = null
ws: WebSocket | null = null
restartPause = 2000
restartTimeout: any = null
status: string = 'connecting'
mounted() {
this.start()
}
// stop the video and close the streams if the component is going to be destroyed so we don't leave hanging streams
beforeDestroy() {
this.terminate()
// clear any potentially open restart timeout
if (this.restartTimeout) clearTimeout(this.restartTimeout)
}
get webcamStyle() {
return {
transform: this.generateTransform(
this.camSettings.flip_horizontal ?? false,
this.camSettings.flip_vertical ?? false,
this.camSettings.rotation ?? 0
),
}
}
get url() {
let urlSearch = ''
let url = new URL(location.href)
try {
urlSearch = new URL(this.camSettings.stream_url).search.toString()
url = new URL('api/ws' + urlSearch, this.camSettings.stream_url)
} catch (e) {
this.log('invalid url', this.camSettings.stream_url)
}
// create media types array
const media = ['video']
if (this.enableAudio) media.push('audio')
url.searchParams.set('media', media.join('+'))
// change protocol to ws
url.protocol = this.$store.state.socket.protocol + ':'
// output a warning, if no src is set in the url
if (!url.searchParams.has('src')) {
this.log('no src set in url')
}
return this.convertUrl(url.toString(), this.printerUrl)
}
get enableAudio() {
return this.camSettings.extra_data?.enableAudio ?? false
}
// stop and restart the video if the url changes
@Watch('url')
changedUrl() {
this.terminate()
this.start()
}
// stop and restart the video if enableAudio changes
@Watch('enableAudio')
changedEnableAudio() {
this.terminate()
this.start()
}
get expanded(): boolean {
return this.$store.getters['gui/getPanelExpand']('webcam-panel', this.viewport) ?? false
}
// start or stop the video when the expand state changes
@Watch('expanded', { immediate: true })
expandChanged(newExpanded: boolean): void {
if (!newExpanded) {
this.terminate()
return
}
this.start()
}
log(msg: string, obj?: any) {
if (obj) {
window.console.log(`[WebRTC go2rtc] ${msg}`, obj)
return
}
window.console.log(`[WebRTC go2rtc] ${msg}`)
}
// webrtc player methods
// adapted from https://github.com/AlexxIT/go2rtc/blob/master/www/webrtc.html
start() {
if (!this.video) {
this.scheduleRestart()
return
}
this.log('connecting to ' + this.url)
this.status = 'connecting'
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
})
let localTracks: MediaStreamTrack[] = []
const kinds = ['video', 'audio']
kinds.forEach((kind: string) => {
const track = this.pc?.addTransceiver(kind, { direction: 'recvonly' }).receiver.track
if (track) localTracks.push(track)
})
this.video.srcObject = new MediaStream(localTracks)
this.ws = new WebSocket(this.url)
this.ws.addEventListener('open', () => this.onWebSocketOpen())
this.ws.addEventListener('message', (ev) => this.onWebSocketMessage(ev))
this.ws.addEventListener('close', (ev) => this.onWebSocketClose(ev))
}
onWebSocketOpen() {
this.log('open')
if (this.restartTimeout !== null) {
clearTimeout(this.restartTimeout)
this.restartTimeout = null
}
this.pc?.addEventListener('icecandidate', (ev) => {
if (!ev.candidate) return
const msg = { type: 'webrtc/candidate', value: ev.candidate.candidate }
this.ws?.send(JSON.stringify(msg))
})
this.pc?.addEventListener('connectionstatechange', () => {
this.status = (this.pc?.connectionState ?? '').toString()
this.log('connection state changed', this.status)
if (['failed', 'disconnected'].includes(this.status)) {
this.scheduleRestart()
}
})
this.pc
?.createOffer()
.then((offer) => this.pc?.setLocalDescription(offer))
.then(() => {
const msg = { type: 'webrtc/offer', value: this.pc?.localDescription?.sdp }
this.ws?.send(JSON.stringify(msg))
})
}
onWebSocketMessage(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if (msg.type === 'webrtc/candidate') {
this.pc?.addIceCandidate({ candidate: msg.value, sdpMid: '0' })
} else if (msg.type === 'webrtc/answer') {
this.pc?.setRemoteDescription({ type: 'answer', sdp: msg.value })
}
}
onWebSocketClose(ev: CloseEvent) {
this.log('close')
this.status = 'disconnected'
if (!ev.wasClean) this.scheduleRestart()
}
terminate() {
this.log('terminating')
if (this.pc !== null) {
this.pc.close()
this.pc = null
}
if (this.ws !== null) {
this.ws.close()
this.ws = null
}
}
scheduleRestart() {
if (this.restartTimeout !== null) return
this.terminate()
this.restartTimeout = window.setTimeout(() => {
this.restartTimeout = null
this.start()
}, this.restartPause)
}
}
</script>

<style scoped>
.webcamImage {
width: 100%;
}
._webcam_webrtc_output {
aspect-ratio: calc(3 / 2);
}
video {
width: 100%;
}
</style>
1 change: 1 addition & 0 deletions src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,7 @@
"CreateWebcam": "Erstelle Webcam",
"EditCrowsnestConf": "crowsnest.conf bearbeiten",
"EditWebcam": "Webcam bearbeiten",
"EnableAudio": "Ton einschalten",
"FlipWebcam": "Webcam-Bild spiegeln:",
"HideFps": "FPS-Anzeige verstecken",
"Hlsstream": "HLS-Stream",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,7 @@
"CreateWebcam": "Create Webcam",
"EditCrowsnestConf": "Edit crowsnest.conf",
"EditWebcam": "Edit Webcam",
"EnableAudio": "Enable audio",
"FlipWebcam": "Flip webcam image:",
"HideFps": "Hide FPS counter",
"Hlsstream": "HLS Stream",
Expand Down Expand Up @@ -1194,6 +1195,7 @@
"Vertically": "vertically",
"Webcams": "Webcams",
"WebrtcCameraStreamer": "WebRTC (camera-streamer)",
"WebrtcGo2rtc": "WebRTC (go2rtc)",
"WebrtcJanus": "WebRTC (janus-gateway)",
"WebrtcMediaMTX": "WebRTC (MediaMTX)"
}
Expand Down
1 change: 1 addition & 0 deletions src/store/gui/webcams/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface GuiWebcamStateWebcam {
rotation: number
aspect_ratio?: string
extra_data?: {
enableAudio?: boolean
hideFps?: boolean
}
source?: 'config' | 'database'
Expand Down
Loading