From 5767e98501fb331a1bb41dce59d1395d39e78e6f Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Feb 2023 09:07:06 -0800 Subject: [PATCH 1/5] Initial support for go2rtc provider. --- src/camera-manager/manager.ts | 2 +- src/card.ts | 2 + src/components/live/live-go2rtc.ts | 146 +++++ src/components/live/live-ha.ts | 4 +- src/components/live/live-image.ts | 4 +- src/components/live/live-jsmpeg.ts | 8 +- .../{live-webrtc.ts => live-webrtc-card.ts} | 4 +- src/components/live/live.ts | 31 +- src/editor.ts | 4 + src/external/go2rtc/README.md | 9 + src/external/go2rtc/video-rtc.d.ts | 22 + src/external/go2rtc/video-rtc.js | 597 ++++++++++++++++++ src/localize/languages/en.json | 3 +- src/localize/languages/it.json | 4 +- src/localize/languages/pt-BR.json | 4 +- src/scss/live-go2rtc.scss | 17 + src/scss/{live-frigate.scss => live-ha.scss} | 0 src/scss/live-image.scss | 5 + ...live-webrtc.scss => live-webrtc-card.scss} | 0 src/types.ts | 9 +- 20 files changed, 850 insertions(+), 25 deletions(-) create mode 100644 src/components/live/live-go2rtc.ts rename src/components/live/{live-webrtc.ts => live-webrtc-card.ts} (98%) create mode 100644 src/external/go2rtc/README.md create mode 100644 src/external/go2rtc/video-rtc.d.ts create mode 100644 src/external/go2rtc/video-rtc.js create mode 100644 src/scss/live-go2rtc.scss rename src/scss/{live-frigate.scss => live-ha.scss} (100%) create mode 100644 src/scss/live-image.scss rename src/scss/{live-webrtc.scss => live-webrtc-card.scss} (100%) diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index a39d64ac..a1d6b433 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -43,7 +43,7 @@ import { getCameraID } from '../utils/camera.js'; import { localize } from '../localize/localize.js'; import { CameraInitializationError } from './error.js'; import { CameraManagerStore } from './store.js'; -import { cloneDeep } from 'lodash-es'; +import cloneDeep from 'lodash-es/cloneDeep'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; class QueryClassifier { diff --git a/src/card.ts b/src/card.ts index 61ec4c25..1b16575b 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1641,6 +1641,8 @@ class FrigateCard extends LitElement { return; } + log(this._cardWideConfig, `Frigate Card media load: `, mediaLoadedInfo); + this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo; // An update may be required to draw elements. diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts new file mode 100644 index 00000000..cc06f16e --- /dev/null +++ b/src/components/live/live-go2rtc.ts @@ -0,0 +1,146 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import liveMSEStyle from '../../scss/live-mse-webrtc.scss'; +import { CameraConfig, ExtendedHomeAssistant } from '../../types.js'; +import '../image.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../../utils/media'; +import { dispatchMediaLoadedEvent } from '../../utils/media-info'; +import { localize } from '../../localize/localize'; +import { dispatchErrorMessageEvent } from '../message'; +import { VideoRTC } from '../../external/go2rtc/video-rtc'; +import { homeAssistantSignPath } from '../../utils/ha'; +import { errorToConsole } from '../../utils/basic'; + +// Note (2023-02-18): Depending on the behavior of the player / browser is +// possible this URL will need to be re-signed in order to avoid HA spamming +// logs after the expiry time, but this complexity is not added for now until +// there are verified cases of this being an issue (see equivalent in the JSMPEG +// provider). +const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; + +@customElement('frigate-card-live-go2rtc-player') +class FrigateCardGo2RTCPlayer extends VideoRTC { + public play(): void { + // Let Frigate card control auto playing. + } + + public oninit(): void { + super.oninit(); + + if (this.video) { + const onloadeddata = this.video.onloadeddata; + this.video.onloadeddata = (e) => { + if (onloadeddata) { + onloadeddata.call(this.video, e); + } + hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + dispatchMediaLoadedEvent(this, this.video); + }; + } + } +} + +@customElement('frigate-card-live-go2rtc') +export class FrigateCardGo2RTC extends LitElement { + // Not an reactive property to avoid resetting the video. + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + protected _player?: FrigateCardGo2RTCPlayer; + + public play(): void { + this._player?.video?.play(); + } + + public pause(): void { + this._player?.video?.pause(); + } + + public mute(): void { + if (this._player?.video) { + this._player.video.muted = true; + } + } + + public unmute(): void { + if (this._player?.video) { + this._player.video.muted = false; + } + } + + public seek(seconds: number): void { + if (this._player?.video) { + this._player.video.currentTime = seconds; + } + } + + protected async _createPlayer(): Promise { + if ( + !this.hass || + !this.cameraConfig?.frigate.client_id || + !this.cameraConfig.frigate.camera_name + ) { + return; + } + + let response: string | null | undefined; + try { + response = await homeAssistantSignPath( + this.hass, + `/api/frigate/${this.cameraConfig.frigate.client_id}` + + `/mse/api/ws?src=${this.cameraConfig.frigate.camera_name}`, + GO2RTC_URL_SIGN_EXPIRY_SECONDS, + ); + } catch (e) { + errorToConsole(e as Error); + return; + } + if (!response) { + return; + } + const url = response.replace(/^http/i, 'ws'); + if (!url) { + return dispatchErrorMessageEvent(this, localize('error.failed_sign')); + } + + this._player = new FrigateCardGo2RTCPlayer(); + this._player.src = url; + this._player.visibilityCheck = false; + this._player.background = true; + this._player.mode = 'webrtc'; + + this.requestUpdate(); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameraConfig')) { + this._createPlayer(); + } + } + + protected render(): TemplateResult | void { + return html`${this._player}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveMSEStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-go2rtc': FrigateCardGo2RTC; + } +} diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 2a9e8c0c..0fef2034 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import liveFrigateStyle from '../../scss/live-frigate.scss'; +import liveHAStyle from '../../scss/live-ha.scss'; import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; import { getStateObjOrDispatchError } from './live.js'; import '../../patches/ha-camera-stream'; @@ -83,7 +83,7 @@ export class FrigateCardLiveHA extends LitElement { * Get styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(liveFrigateStyle); + return unsafeCSS(liveHAStyle); } } diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 5f0c6da3..98b2a90d 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -1,7 +1,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import liveFrigateStyle from '../../scss/live-frigate.scss'; +import liveImageStyle from '../../scss/live-image.scss'; import { CameraConfig, LiveImageConfig } from '../../types.js'; import { getStateObjOrDispatchError } from './live.js'; import '../image.js'; @@ -84,7 +84,7 @@ export class FrigateCardLiveImage extends LitElement { * Get styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(liveFrigateStyle); + return unsafeCSS(liveImageStyle); } } diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index e970da19..0b10e733 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -17,10 +17,10 @@ import { dispatchErrorMessageEvent } from '../message.js'; import { contentsChanged, errorToConsole } from '../../utils/basic.js'; // Number of seconds a signed URL is valid for. -const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; +const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; // Number of seconds before the expiry to trigger a refresh. -const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; +const JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; @customElement('frigate-card-live-jsmpeg') export class FrigateCardLiveJSMPEG extends LitElement { @@ -102,7 +102,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { this.hass, `/api/frigate/${this.cameraConfig.frigate.client_id}` + `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, - URL_SIGN_EXPIRY_SECONDS, + JSMPEG_URL_SIGN_EXPIRY_SECONDS, ); } catch (e) { errorToConsole(e as Error); @@ -221,7 +221,7 @@ export class FrigateCardLiveJSMPEG extends LitElement { this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); this._refreshPlayerTimerID = window.setTimeout(() => { this.requestUpdate(); - }, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); + }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); } else { dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); } diff --git a/src/components/live/live-webrtc.ts b/src/components/live/live-webrtc-card.ts similarity index 98% rename from src/components/live/live-webrtc.ts rename to src/components/live/live-webrtc-card.ts index a17d4167..35254b67 100644 --- a/src/components/live/live-webrtc.ts +++ b/src/components/live/live-webrtc-card.ts @@ -3,7 +3,7 @@ import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { localize } from '../../localize/localize.js'; -import liveWebRTCStyle from '../../scss/live-webrtc.scss'; +import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; import { CameraConfig, CardWideConfig, @@ -193,7 +193,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement { * Get styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(liveWebRTCStyle); + return unsafeCSS(liveWebRTCCardStyle); } } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 575c9010..b6969aac 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -573,18 +573,16 @@ export class FrigateCardLiveCarousel extends LitElement { const [prevID, nextID] = this._getCameraIDsOfNeighbors(); - const cameraMetadataPrevious = prevID ? this.cameraManager.getCameraMetadata( - this.hass, - prevID, - ) : null; + const cameraMetadataPrevious = prevID + ? this.cameraManager.getCameraMetadata(this.hass, prevID) + : null; const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( this.hass, this.view.camera, ); - const cameraMetadataNext = nextID ? this.cameraManager.getCameraMetadata( - this.hass, - nextID, - ) : null; + const cameraMetadataNext = nextID + ? this.cameraManager.getCameraMetadata(this.hass, nextID) + : null; // Notes on the below: // - guard() is used to avoid reseting the carousel unless the @@ -607,7 +605,9 @@ export class FrigateCardLiveCarousel extends LitElement { [this.cameraManager, this.liveConfig], this._getPlugins.bind(this), ) as EmblaCarouselPlugins} - .label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}" + .label="${cameraMetadataCurrent + ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` + : ''}" .titlePopupConfig=${config.controls.title} .selected=${this._getSelectedCameraIndex()} transitionEffect=${this._getTransitionEffect()} @@ -796,9 +796,11 @@ export class FrigateCardLiveProvider extends LitElement { } else if (provider === 'ha') { import('./live-ha.js'); } else if (provider === 'webrtc-card') { - import('./live-webrtc.js'); + import('./live-webrtc-card.js'); } else if (provider === 'image') { import('./live-image.js'); + } else if (provider === 'go2rtc') { + import('./live-go2rtc.js'); } } } @@ -850,6 +852,15 @@ export class FrigateCardLiveProvider extends LitElement { @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` + : provider === 'go2rtc' + ? html` + ` : provider === 'webrtc-card' ? html`}} + */ + this.onmessage = null; + } + + /** + * Set video source (WebSocket URL). Support relative path. + * @param {string|URL} value + */ + set src(value) { + if (typeof value !== "string") value = value.toString(); + if (value.startsWith("http")) { + value = "ws" + value.substring(4); + } else if (value.startsWith("/")) { + value = "ws" + location.origin.substring(4) + value; + } + + this.wsURL = value; + + this.onconnect(); + } + + /** + * Play video. Support automute when autoplay blocked. + * https://developer.chrome.com/blog/autoplay/ + */ + play() { + this.video.play().catch(er => { + if (er.name === "NotAllowedError" && !this.video.muted) { + this.video.muted = true; + this.video.play().catch(() => console.debug); + } + }); + } + + /** + * Send message to server via WebSocket + * @param {Object} value + */ + send(value) { + if (this.ws) this.ws.send(JSON.stringify(value)); + } + + codecs(type) { + const test = type === "mse" + ? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`) + : codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`); + return this.CODECS.filter(test).join(); + } + + /** + * `CustomElement`. Invoked each time the custom element is appended into a + * document-connected element. + */ + connectedCallback() { + if (this.disconnectTID) { + clearTimeout(this.disconnectTID); + this.disconnectTID = 0; + } + + // because video autopause on disconnected from DOM + if (this.video) { + const seek = this.video.seekable; + if (seek.length > 0) { + this.video.currentTime = seek.end(seek.length - 1); + } + this.play(); + } else { + this.oninit(); + } + + this.onconnect(); + } + + /** + * `CustomElement`. Invoked each time the custom element is disconnected from the + * document's DOM. + */ + disconnectedCallback() { + if (this.background || this.disconnectTID) return; + if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return; + + this.disconnectTID = setTimeout(() => { + if (this.reconnectTID) { + clearTimeout(this.reconnectTID); + this.reconnectTID = 0; + } + + this.disconnectTID = 0; + + this.ondisconnect(); + }, this.DISCONNECT_TIMEOUT); + } + + /** + * Creates child DOM elements. Called automatically once on `connectedCallback`. + */ + oninit() { + this.video = document.createElement("video"); + this.video.controls = true; + this.video.playsInline = true; + this.video.preload = "auto"; + + this.video.style.display = "block"; // fix bottom margin 4px + this.video.style.width = "100%"; + this.video.style.height = "100%" + + this.appendChild(this.video); + + if (this.background) return; + + if ("hidden" in document && this.visibilityCheck) { + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }) + } + + if ("IntersectionObserver" in window && this.visibilityThreshold) { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (!entry.isIntersecting) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + }, {threshold: this.visibilityThreshold}); + observer.observe(this); + } + } + + /** + * Connect to WebSocket. Called automatically on `connectedCallback`. + * @return {boolean} true if the connection has started. + */ + onconnect() { + if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false; + + // CLOSED or CONNECTING => CONNECTING + this.wsState = WebSocket.CONNECTING; + + this.connectTS = Date.now(); + + this.ws = new WebSocket(this.wsURL); + this.ws.binaryType = "arraybuffer"; + this.ws.addEventListener("open", ev => this.onopen(ev)); + this.ws.addEventListener("close", ev => this.onclose(ev)); + + return true; + } + + ondisconnect() { + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.close(); + this.pc = null; + } + } + + /** + * @returns {Array.} of modes (mse, webrtc, etc.) + */ + onopen() { + // CONNECTING => OPEN + this.wsState = WebSocket.OPEN; + + this.ws.addEventListener("message", ev => { + if (typeof ev.data === "string") { + const msg = JSON.parse(ev.data); + for (const mode in this.onmessage) { + this.onmessage[mode](msg); + } + } else { + this.ondata(ev.data); + } + }); + + this.ondata = null; + this.onmessage = {}; + + const modes = []; + + if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone + modes.push("mse"); + this.onmse(); + } else if (this.mode.indexOf("mp4") >= 0) { + modes.push("mp4"); + this.onmp4(); + } + + if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app + modes.push("webrtc"); + this.onwebrtc(); + } + + if (this.mode.indexOf("mjpeg") >= 0) { + if (modes.length) { + this.onmessage["mjpeg"] = msg => { + if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return; + this.onmjpeg(); + } + } else { + modes.push("mjpeg"); + this.onmjpeg(); + } + } + + return modes; + } + + /** + * @return {boolean} true if reconnection has started. + */ + onclose() { + if (this.wsState === WebSocket.CLOSED) return false; + + // CONNECTING, OPEN => CONNECTING + this.wsState = WebSocket.CONNECTING; + this.ws = null; + + // reconnect no more than once every X seconds + const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0); + + this.reconnectTID = setTimeout(() => { + this.reconnectTID = 0; + this.onconnect(); + }, delay); + + return true; + } + + onmse() { + const ms = new MediaSource(); + ms.addEventListener("sourceopen", () => { + URL.revokeObjectURL(this.video.src); + this.send({type: "mse", value: this.codecs("mse")}); + }, {once: true}); + + this.video.src = URL.createObjectURL(ms); + this.video.srcObject = null; + this.play(); + + this.mseCodecs = ""; + + this.onmessage["mse"] = msg => { + if (msg.type !== "mse") return; + + this.mseCodecs = msg.value; + + const sb = ms.addSourceBuffer(msg.value); + sb.mode = "segments"; // segments or sequence + sb.addEventListener("updateend", () => { + if (sb.updating) return; + + try { + if (bufLen > 0) { + const data = buf.slice(0, bufLen); + bufLen = 0; + sb.appendBuffer(data); + } else if (sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1) - 15; + const start = sb.buffered.start(0); + if (end > start) { + sb.remove(start, end); + ms.setLiveSeekableRange(end, end + 15); + } + // console.debug("VideoRTC.buffered", start, end); + } + } catch (e) { + // console.debug(e); + } + }); + + const buf = new Uint8Array(2 * 1024 * 1024); + let bufLen = 0; + + this.ondata = data => { + if (sb.updating || bufLen > 0) { + const b = new Uint8Array(data); + buf.set(b, bufLen); + bufLen += b.byteLength; + // console.debug("VideoRTC.buffer", b.byteLength, bufLen); + } else { + try { + sb.appendBuffer(data); + } catch (e) { + // console.debug(e); + } + } + } + } + } + + onwebrtc() { + const pc = new RTCPeerConnection(this.pcConfig); + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement("video"); + video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true}); + + pc.addEventListener("icecandidate", ev => { + const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ""; + this.send({type: "webrtc/candidate", value: candidate}); + }); + + pc.addEventListener("track", ev => { + // when stream already init + if (video2.srcObject !== null) return; + + // when audio track not exist in Chrome + if (ev.streams.length === 0) return; + + // when audio track not exist in Firefox + if (ev.streams[0].id[0] === '{') return; + + video2.srcObject = ev.streams[0]; + }); + + pc.addEventListener("connectionstatechange", () => { + if (pc.connectionState === "failed" || pc.connectionState === "disconnected") { + pc.close(); // stop next events + + this.pcState = WebSocket.CLOSED; + this.pc = null; + + this.onconnect(); + } + }); + + this.onmessage["webrtc"] = msg => { + switch (msg.type) { + case "webrtc/candidate": + pc.addIceCandidate({ + candidate: msg.value, + sdpMid: "0" + }).catch(() => console.debug); + break; + case "webrtc/answer": + pc.setRemoteDescription({ + type: "answer", + sdp: msg.value + }).catch(() => console.debug); + break; + case "error": + if (msg.value.indexOf("webrtc/offer") < 0) return; + pc.close(); + } + }; + + // Safari doesn't support "offerToReceiveVideo" + pc.addTransceiver("video", {direction: "recvonly"}); + pc.addTransceiver("audio", {direction: "recvonly"}); + + pc.createOffer().then(offer => { + pc.setLocalDescription(offer).then(() => { + this.send({type: "webrtc/offer", value: offer.sdp}); + }); + }); + + this.pcState = WebSocket.CONNECTING; + this.pc = pc; + } + + /** + * @param ev {Event} + */ + onpcvideo(ev) { + if (!this.pc) return; + + /** @type {HTMLVideoElement} */ + const video2 = ev.target; + const state = this.pc.connectionState; + + // Firefox doesn't support pc.connectionState + if (state === "connected" || state === "connecting" || !state) { + // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE + let rtcPriority = 0, msePriority = 0; + + /** @type {MediaStream} */ + const ms = video2.srcObject; + if (ms.getVideoTracks().length > 0) rtcPriority += 0x220; + if (ms.getAudioTracks().length > 0) rtcPriority += 0x102; + + if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230; + if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210; + if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101; + + if (rtcPriority >= msePriority) { + this.video.srcObject = ms; + this.play(); + + this.pcState = WebSocket.OPEN; + + this.wsState = WebSocket.CLOSED; + this.ws.close(); + this.ws = null; + } else { + this.pcState = WebSocket.CLOSED; + this.pc.close(); + this.pc = null; + } + } + + video2.srcObject = null; + } + + onmjpeg() { + this.ondata = data => { + this.video.controls = false; + this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data); + }; + + this.send({type: "mjpeg"}); + } + + onmp4() { + /** @type {HTMLCanvasElement} **/ + const canvas = document.createElement("canvas"); + /** @type {CanvasRenderingContext2D} */ + let context; + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement("video"); + video2.autoplay = true; + video2.playsInline = true; + video2.muted = true; + + video2.addEventListener("loadeddata", ev => { + if (!context) { + canvas.width = video2.videoWidth; + canvas.height = video2.videoHeight; + context = canvas.getContext('2d'); + } + + context.drawImage(video2, 0, 0, canvas.width, canvas.height); + + this.video.controls = false; + this.video.poster = canvas.toDataURL("image/jpeg"); + }); + + this.ondata = data => { + video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data); + }; + + this.send({type: "mp4", value: this.codecs("mp4")}); + } + + static btoa(buffer) { + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + let binary = ""; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c11e8476..1f818c0f 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -29,7 +29,8 @@ "live_providers": { "auto": "Automatic", "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC native)", + "go2rtc": "go2rtc", + "ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC via HA)", "image": "Home Assistant images", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 1d3204fe..3544dff4 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -29,7 +29,9 @@ "live_providers": { "auto": "Automatica", "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)", + "go2rtc": "", + "ha": "", + "image": "", "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" }, "title": "Titolo per questa telecamera (Autoidentificato dall'entità)", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 3af8326e..13f24aa7 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -29,7 +29,9 @@ "live_providers": { "auto": "Automatico", "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", + "go2rtc": "", + "ha": "", + "image": "", "webrtc-card": "Cartão WebRTC (de @AlexxIT)" }, "title": "Título para esta câmera (detectado automaticamente pela entidade)", diff --git a/src/scss/live-go2rtc.scss b/src/scss/live-go2rtc.scss new file mode 100644 index 00000000..ebd2dc5b --- /dev/null +++ b/src/scss/live-go2rtc.scss @@ -0,0 +1,17 @@ +@use 'media-layout.scss'; + +:host { + width: 100%; + height: 100%; + display: block; +} +video { + @include media-layout.media-layout(); + + // Note: These 3 properties will also be set directly on the video element by + // the player. They are included here for completeness, or should the + // underlying player change its behavior in future. + width: 100%; + height: 100%; + display: block; +} diff --git a/src/scss/live-frigate.scss b/src/scss/live-ha.scss similarity index 100% rename from src/scss/live-frigate.scss rename to src/scss/live-ha.scss diff --git a/src/scss/live-image.scss b/src/scss/live-image.scss new file mode 100644 index 00000000..f0f64c07 --- /dev/null +++ b/src/scss/live-image.scss @@ -0,0 +1,5 @@ +:host { + width: 100%; + height: 100%; + display: block; +} \ No newline at end of file diff --git a/src/scss/live-webrtc.scss b/src/scss/live-webrtc-card.scss similarity index 100% rename from src/scss/live-webrtc.scss rename to src/scss/live-webrtc-card.scss diff --git a/src/types.ts b/src/types.ts index 2b0ae257..133d09e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,7 +66,14 @@ const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS; const FRIGATE_MENU_PRIORITY_DEFAULT = 50; export const FRIGATE_MENU_PRIORITY_MAX = 100; -const LIVE_PROVIDERS = ['auto', 'image', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const; +const LIVE_PROVIDERS = [ + 'auto', + 'image', + 'ha', + 'frigate-jsmpeg', + 'go2rtc', + 'webrtc-card', +] as const; export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; const MEDIA_ACTION_NEGATIVE_CONDITIONS = [ From f5ce02fe4aa157ae20eaa70536f9103080a5d432 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Feb 2023 11:03:12 -0800 Subject: [PATCH 2/5] Generalize camera endpoints. --- src/camera-manager/engine.ts | 9 +- src/camera-manager/frigate/engine-frigate.ts | 124 +++++++++++++------ src/camera-manager/generic/engine-generic.ts | 9 +- src/camera-manager/manager.ts | 10 +- src/camera-manager/types.ts | 13 +- src/card.ts | 21 ++-- src/components/live/live-go2rtc.ts | 48 ++++--- src/components/live/live-jsmpeg.ts | 68 ++++------ src/components/live/live.ts | 10 ++ src/localize/languages/en.json | 2 +- src/localize/languages/it.json | 2 +- src/localize/languages/pt-BR.json | 2 +- src/utils/endpoint.ts | 33 +++++ 13 files changed, 219 insertions(+), 132 deletions(-) create mode 100644 src/utils/endpoint.ts diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 533a02db..833990be 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -18,9 +18,10 @@ import { CameraManagerCameraCapabilities, CameraManagerMediaCapabilities, CameraManagerCameraMetadata, - CameraURLContext, + CameraEndpointsContext, CameraConfigs, Engine, + CameraEndpoints, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -118,8 +119,8 @@ export interface CameraManagerEngine { getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; - getCameraURL( + getCameraEndpoints( cameraConfig: CameraConfig, - context?: CameraURLContext, - ): string | null; + context?: CameraEndpointsContext, + ): CameraEndpoints | null; } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index dc63dc76..04db5054 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -36,8 +36,10 @@ import { RecordingSegment, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraURLContext, + CameraEndpointsContext, CameraConfigs, + CameraEndpoints, + CameraEndpoint, } from '../types'; import { FrigateRecording } from './types'; import { @@ -1006,47 +1008,91 @@ export class FrigateCameraManagerEngine }; } - public getCameraURL( + public getCameraEndpoints( cameraConfig: CameraConfig, - context?: CameraURLContext, - ): string | null { - if (!cameraConfig.frigate.url) { - return null; - } - if (!cameraConfig.frigate.camera_name) { - return cameraConfig.frigate.url; - } + context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const getUIEndpoint = (): CameraEndpoint | null => { + if (!cameraConfig.frigate.url) { + return null; + } + if (!cameraConfig.frigate.camera_name) { + return { endpoint: cameraConfig.frigate.url }; + } - const eventsURL = - `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; - const recordingsURL = - `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; + const cameraURL = + `${cameraConfig.frigate.url}/cameras/` + cameraConfig.frigate.camera_name; - // If media is available, use it since it may result in a more precisely - // correct URL. - switch (context?.media?.getMediaType()) { - case 'clip': - case 'snapshot': - return eventsURL; - case 'recording': - const startTime = context.media.getStartTime(); - if (startTime) { - return recordingsURL + format(startTime, 'yyyy-MM-dd/HH'); + if (context?.view === 'live') { + return { endpoint: cameraURL }; + } + + const eventsURL = + `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; + const recordingsURL = + `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; + + // If media is available, use it since it may result in a more precisely + // correct URL. + switch (context?.media?.getMediaType()) { + case 'clip': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + const startTime = context.media.getStartTime(); + if (startTime) { + return { endpoint: recordingsURL + format(startTime, 'yyyy-MM-dd/HH') }; + } + } + + // Otherwise, fall back to just using the view if we have that. + switch (context?.view) { + case 'clip': + case 'clips': + case 'snapshots': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + case 'recordings': + return { endpoint: recordingsURL }; + } + + return { + endpoint: cameraURL, + }; + }; + + const getGo2RTC = (): CameraEndpoint | null => { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + // go2rtc is exposed by the integration under the (slightly + // misleading) 'mse' path, even though that path can serve all go2rtc + // modes. + `/mse/api/ws?src=${cameraConfig.frigate.camera_name}`, + sign: true, + }; + }; + + const getJSMPEG = (): CameraEndpoint | null => { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/jsmpeg/${cameraConfig.frigate.camera_name}`, + sign: true, + }; + }; + + const ui = getUIEndpoint(); + const go2rtc = getGo2RTC(); + const jsmpeg = getJSMPEG(); + + return ui || go2rtc || jsmpeg + ? { + ...(ui && { ui: ui }), + ...(go2rtc && { go2rtc: go2rtc }), + ...(jsmpeg && { jsmpeg: jsmpeg }), } - } - - // Otherwise, fall back to just using the view if we have that. - switch (context?.view) { - case 'clip': - case 'clips': - case 'snapshots': - case 'snapshot': - return eventsURL; - case 'recording': - case 'recordings': - return recordingsURL; - } - - return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`; + : null; } } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 6cbd0110..3a990414 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -16,12 +16,13 @@ import { RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraURLContext, + CameraEndpointsContext, CameraConfigs, RecordingQuery, QueryReturnType, CameraManagerCameraCapabilities, Engine, + CameraEndpoints, } from '../types'; import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; @@ -177,10 +178,10 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return null; } - public getCameraURL( + public getCameraEndpoints( _cameraConfig: CameraConfig, - _context?: CameraURLContext, - ): string | null { + _context?: CameraEndpointsContext, + ): CameraEndpoints | null { return null; } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index a1d6b433..9665a167 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -6,7 +6,7 @@ import { CameraManagerCameraMetadata, CameraManagerCapabilities, CameraManagerMediaCapabilities, - CameraURLContext, + CameraEndpointsContext, DataQuery, EventQuery, EventQueryResults, @@ -29,6 +29,7 @@ import { RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, + CameraEndpoints, } from './types.js'; import orderBy from 'lodash-es/orderBy'; import { CameraManagerEngineFactory } from './engine-factory.js'; @@ -643,13 +644,16 @@ export class CameraManager { ); } - public getCameraURL(cameraID: string, context?: CameraURLContext): string | null { + public getCameraEndpoints( + cameraID: string, + context?: CameraEndpointsContext, + ): CameraEndpoints | null { const cameraConfig = this._store.getCameraConfig(cameraID); const engine = this._store.getEngineForCameraID(cameraID); if (!cameraConfig || !engine) { return null; } - return engine.getCameraURL(cameraConfig, context); + return engine.getCameraEndpoints(cameraConfig, context); } public getCameraMetadata( diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index ee660957..73ab4506 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -106,11 +106,22 @@ export interface CameraManagerCameraMetadata { icon: string; } -export interface CameraURLContext { +export interface CameraEndpointsContext { media?: ViewMedia; view?: FrigateCardView; } +export interface CameraEndpoint { + endpoint: string; + sign?: boolean; +} + +export interface CameraEndpoints { + ui?: CameraEndpoint; + go2rtc?: CameraEndpoint; + jsmpeg?: CameraEndpoint; +} + export type CameraConfigs = Map; // =========== diff --git a/src/card.ts b/src/card.ts index 1b16575b..f055b4c2 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1461,15 +1461,18 @@ class FrigateCard extends LitElement { * @returns The URL or null if unavailable. */ protected _getCameraURLFromContext(): string | null { - const view = this._view; - const selectedCameraID = view?.camera; - const media = view?.queryResults?.getSelectedResult() ?? null; - return this._hass && view && selectedCameraID - ? this._cameraManager?.getCameraURL(selectedCameraID, { - ...(media && { media: media }), - ...(view && { view: view.view }), - }) ?? null - : null; + if (!this._view) { + return null; + } + + const selectedCameraID = this._view.camera; + const media = this._view.queryResults?.getSelectedResult() ?? null; + const endpoints = + this._cameraManager?.getCameraEndpoints(selectedCameraID, { + view: this._view.view, + ...(media && { media: media }), + }) ?? null; + return endpoints?.ui?.endpoint ?? null; } /** diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index cc06f16e..20509c73 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -7,7 +7,7 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import liveMSEStyle from '../../scss/live-mse-webrtc.scss'; +import liveMSEStyle from '../../scss/live-go2rtc.scss'; import { CameraConfig, ExtendedHomeAssistant } from '../../types.js'; import '../image.js'; import { @@ -18,8 +18,8 @@ import { dispatchMediaLoadedEvent } from '../../utils/media-info'; import { localize } from '../../localize/localize'; import { dispatchErrorMessageEvent } from '../message'; import { VideoRTC } from '../../external/go2rtc/video-rtc'; -import { homeAssistantSignPath } from '../../utils/ha'; -import { errorToConsole } from '../../utils/basic'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; // Note (2023-02-18): Depending on the behavior of the player / browser is // possible this URL will need to be re-signed in order to avoid HA spamming @@ -58,6 +58,9 @@ export class FrigateCardGo2RTC extends LitElement { @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + protected _player?: FrigateCardGo2RTCPlayer; public play(): void { @@ -87,36 +90,29 @@ export class FrigateCardGo2RTC extends LitElement { } protected async _createPlayer(): Promise { - if ( - !this.hass || - !this.cameraConfig?.frigate.client_id || - !this.cameraConfig.frigate.camera_name - ) { + if (!this.hass) { return; } - let response: string | null | undefined; - try { - response = await homeAssistantSignPath( - this.hass, - `/api/frigate/${this.cameraConfig.frigate.client_id}` + - `/mse/api/ws?src=${this.cameraConfig.frigate.camera_name}`, - GO2RTC_URL_SIGN_EXPIRY_SECONDS, - ); - } catch (e) { - errorToConsole(e as Error); - return; + const endpoint = this.cameraEndpoints?.go2rtc; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + context: this.cameraConfig, + }); } - if (!response) { + + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + GO2RTC_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { return; } - const url = response.replace(/^http/i, 'ws'); - if (!url) { - return dispatchErrorMessageEvent(this, localize('error.failed_sign')); - } this._player = new FrigateCardGo2RTCPlayer(); - this._player.src = url; + this._player.src = address; this._player.visibilityCheck = false; this._player.background = true; this._player.mode = 'webrtc'; @@ -125,7 +121,7 @@ export class FrigateCardGo2RTC extends LitElement { } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('cameraConfig')) { + if (changedProps.has('cameraEndpoints')) { this._createPlayer(); } } diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index 0b10e733..55f3ef9c 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -11,10 +11,11 @@ import { ExtendedHomeAssistant, JSMPEGConfig, } from '../../types.js'; -import { homeAssistantSignPath } from '../../utils/ha'; import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; import { dispatchErrorMessageEvent } from '../message.js'; -import { contentsChanged, errorToConsole } from '../../utils/basic.js'; +import { contentsChanged } from '../../utils/basic.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -27,6 +28,9 @@ export class FrigateCardLiveJSMPEG extends LitElement { @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + @property({ attribute: false, hasChanged: contentsChanged }) public jsmpegConfig?: JSMPEGConfig; @@ -83,37 +87,6 @@ export class FrigateCardLiveJSMPEG extends LitElement { // JSMPEG does not support seeking. } - /** - * Get a signed player URL. - * @returns A URL or null. - */ - protected async _getURL(): Promise { - if ( - !this.hass || - !this.cameraConfig?.frigate.client_id || - !this.cameraConfig?.frigate.camera_name - ) { - return null; - } - - let response: string | null | undefined; - try { - response = await homeAssistantSignPath( - this.hass, - `/api/frigate/${this.cameraConfig.frigate.client_id}` + - `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, - JSMPEG_URL_SIGN_EXPIRY_SECONDS, - ); - } catch (e) { - errorToConsole(e as Error); - return null; - } - if (!response) { - return null; - } - return response.replace(/^http/i, 'ws'); - } - /** * Create a JSMPEG player. * @param url The URL for the player to connect to. @@ -205,26 +178,35 @@ export class FrigateCardLiveJSMPEG extends LitElement { * Refresh the JSMPEG player. */ protected async _refreshPlayer(): Promise { + if (!this.hass) { + return; + } this._resetPlayer(); this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; - if (!this.cameraConfig?.frigate.camera_name) { - return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { + const endpoint = this.cameraEndpoints?.jsmpeg; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { context: this.cameraConfig, }); } - const url = await this._getURL(); - if (url) { - this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); - this._refreshPlayerTimerID = window.setTimeout(() => { - this.requestUpdate(); - }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); - } else { - dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + JSMPEG_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { + return; } + + this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address); + this._refreshPlayerTimerID = window.setTimeout(() => { + this.requestUpdate(); + }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); } /** diff --git a/src/components/live/live.ts b/src/components/live/live.ts index b6969aac..d0d008f5 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -54,6 +54,7 @@ import { CameraManager } from '../../camera-manager/manager.js'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; +import { CameraEndpoints } from '../../camera-manager/types.js'; /** * Get the state object or dispatch an error. Used in `ha` and `image` live @@ -514,6 +515,10 @@ export class FrigateCardLiveCarousel extends LitElement { this.cameraManager?.getCameraEndpoints(cameraID), + )} .label=${cameraMetadata?.title ?? ''} .liveConfig=${config} .hass=${this.hass} @@ -666,6 +671,9 @@ export class FrigateCardLiveProvider extends LitElement { @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + @property({ attribute: false }) public liveConfig?: LiveConfig; @@ -858,6 +866,7 @@ export class FrigateCardLiveProvider extends LitElement { class=${classMap(providerClasses)} .hass=${this.hass} .cameraConfig=${this.cameraConfig} + .cameraEndpoints=${this.cameraEndpoints} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -878,6 +887,7 @@ export class FrigateCardLiveProvider extends LitElement { class=${classMap(providerClasses)} .hass=${this.hass} .cameraConfig=${this.cameraConfig} + .cameraEndpoints=${this.cameraEndpoints} .jsmpegConfig=${this.liveConfig.jsmpeg} .cardWideConfig=${this.cardWideConfig} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1f818c0f..0cf1b12e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -353,7 +353,7 @@ "invalid_elements_config": "Invalid picture elements configuration", "invalid_response": "Received invalid response from Home Assistant for request", "jsmpeg_no_player": "Could not start JSMPEG player", - "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", + "live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)", "live_camera_not_found": "The configured camera_entity was not found", "live_camera_unavailable": "Camera unavailable", "no_camera_engine": "Could not determine suitable engine for camera", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 3544dff4..e5a2f728 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -342,7 +342,7 @@ "invalid_elements_config": "Configurazione degli elementi di immagine non valida", "invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta", "jsmpeg_no_player": "Impossibile avviare JSMPEG Player", - "jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG", + "live_camera_no_endpoint": "", "live_camera_not_found": "La telecamera configurata non è stata trovata", "live_camera_unavailable": "Telecamera non disponibile", "no_camera_engine": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 13f24aa7..969c0c47 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -342,7 +342,7 @@ "invalid_elements_config": "Configuração de elementos de imagem inválida", "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", - "jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", + "live_camera_no_endpoint": "", "live_camera_not_found": "", "live_camera_unavailable": "", "no_camera_engine": "", diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts new file mode 100644 index 00000000..58e5605b --- /dev/null +++ b/src/utils/endpoint.ts @@ -0,0 +1,33 @@ +import { CameraEndpoint } from '../camera-manager/types'; +import { dispatchErrorMessageEvent } from '../components/message'; +import { localize } from '../localize/localize'; +import { ExtendedHomeAssistant } from '../types'; +import { errorToConsole } from './basic'; +import { homeAssistantSignPath } from './ha'; + +export const getEndpointAddressOrDispatchError = async ( + element: HTMLElement, + hass: ExtendedHomeAssistant, + endpoint: CameraEndpoint, + expires?: number, +): Promise => { + let address: string | null; + if (!endpoint.sign) { + address = endpoint.endpoint; + } else { + let response: string | null | undefined; + try { + response = await homeAssistantSignPath(hass, endpoint.endpoint, expires); + } catch (e) { + errorToConsole(e as Error); + return null; + } + address = response ? response.replace(/^http/i, 'ws') : null; + } + + if (!address) { + dispatchErrorMessageEvent(element, localize('error.failed_sign')); + return null; + } + return address; +}; From d446716b7b23ec7e2ddd82a34ecd88ea7dd0f2c6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Feb 2023 11:36:35 -0800 Subject: [PATCH 3/5] Add editor support for go2rtc modes. --- src/components/live/live-go2rtc.ts | 4 +++- src/const.ts | 1 + src/editor.ts | 27 +++++++++++++++++++++++++++ src/localize/languages/en.json | 10 ++++++++++ src/types.ts | 6 ++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 20509c73..00fd0c34 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -115,7 +115,9 @@ export class FrigateCardGo2RTC extends LitElement { this._player.src = address; this._player.visibilityCheck = false; this._player.background = true; - this._player.mode = 'webrtc'; + if (this.cameraConfig?.go2rtc?.modes) { + this._player.mode = this.cameraConfig.go2rtc.modes.join(','); + } this.requestUpdate(); } diff --git a/src/const.ts b/src/const.ts index 0e49a7a4..0194dc92 100644 --- a/src/const.ts +++ b/src/const.ts @@ -12,6 +12,7 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_LABEL = `${CONF_CAMERAS}.#.frigate.label` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_ZONE = `${CONF_CAMERAS}.#.frigate.zone` as const; +export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const; export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; diff --git a/src/editor.ts b/src/editor.ts index 3f4283dc..a5e68f65 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -129,6 +129,7 @@ import { CONF_VIEW_UPDATE_SECONDS, CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, MEDIA_CHUNK_SIZE_MAX, + CONF_CAMERAS_ARRAY_GO2RTC_MODES, } from './const.js'; import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; @@ -156,6 +157,7 @@ const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; +const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; const MENU_CAMERAS_WEBRTC = 'cameras.webrtc'; const MENU_IMAGE_LAYOUT = 'image.layout'; @@ -491,6 +493,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'high', label: localize('config.performance.profiles.high') }, ]; + protected _go2rtcModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'mse', label: localize('config.cameras.go2rtc.modes.mse') }, + { value: 'webrtc', label: localize('config.cameras.go2rtc.modes.webrtc') }, + { value: 'mp4', label: localize('config.cameras.go2rtc.modes.mp4') }, + { value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -1404,6 +1414,23 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, )}`, )} + ${this._putInSubmenu( + MENU_CAMERAS_GO2RTC, + cameraIndex, + 'config.cameras.go2rtc.editor_label', + { name: 'mdi:alpha-g-circle' }, + html`${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_GO2RTC_MODES, cameraIndex), + this._go2rtcModes, + { + multiple: true, + label: localize('config.cameras.go2rtc.modes.editor_label') + } + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), + )}`, + )} ${this._putInSubmenu( MENU_CAMERAS_WEBRTC, cameraIndex, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 0cf1b12e..42fd0051 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -23,6 +23,16 @@ "url": "Frigate server URL", "zone": "Frigate zone" }, + "go2rtc": { + "editor_label": "go2rtc Options", + "modes": { + "editor_label": "go2rtc Modes", + "mse": "Media Source Extensions (MSE)", + "webrtc": "Web Real-Time Communication (WebRTC)", + "mp4": "MPEG-4 (MP4)", + "mjpeg": "Motion JPEG (MJPEG)" + } + }, "icon": "Icon for this camera (Autodetected from entity)", "id": "Unique id for this camera in this card", "live_provider": "Live view provider for this camera", diff --git a/src/types.ts b/src/types.ts index 133d09e3..5270a820 100644 --- a/src/types.ts +++ b/src/types.ts @@ -435,6 +435,12 @@ const cameraConfigSchema = z }) .default(cameraConfigDefault.frigate), + go2rtc: z + .object({ + modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array(), + }) + .optional(), + // Camera identifiers for WebRTC. webrtc_card: webrtcCardCameraConfigSchema.optional(), From f67c3252e741a29112993c355a37be8d0c28a0e0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Feb 2023 12:44:14 -0800 Subject: [PATCH 4/5] Update README for go2rtc. --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index b6ac4446..b277c6b5 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) |`ha` (Native WebRTC)|Best|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser.| |`image`|Poor|Poor|Best|Builtin|Use refreshing snapshots of the built-in Home Assistant camera streams.| |`frigate-jsmpeg`|Better|Low|Poor|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| +|`go2rtc`|Best|High|Better|Builtin|Uses [go2rtc](https://github.com/AlexxIT/go2rtc) to stream live feeds. This is supported by Frigate >= `0.12`.| |`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).| @@ -151,6 +152,19 @@ cameras: | `zone` | | :heavy_multiplication_x: | A Frigate zone used to filter events (clips & snapshots), e.g. `front_door`.| | `client_id` | `frigate` | :heavy_multiplication_x: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).| +#### Camera go2rtc configuration + +The `go2rtc` block configures use of the `go2rtc` live provider. This configuration is included as part of a camera entry in the `cameras` array. + +```yaml +cameras: + - go2rtc: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `modes` | `[webrtc, mse, mp4, mjpeg]` | :heavy_multiplication_x: | An ordered array of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. | + #### Camera WebRTC Card configuration The `webrtc_card` block configures only the entity/URL for this camera to be used with the WebRTC Card live provider. This configuration is included as part of a camera entry in the `cameras` array. @@ -1357,6 +1371,14 @@ cameras: - binary_sensor.entrance_sensor dependencies: all_cameras: false + - camera_entity: camera.sitting_room + live_provider: go2rtc + go2rtc: + modes: + - webrtc + - mse + - mp4 + - mjpeg ``` From 9ed3543023d0b6f3245fb33536e49aa2edaf5b0e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Feb 2023 13:08:07 -0800 Subject: [PATCH 5/5] Rename `frigate-jsmpeg` to `jsmpeg`. --- .devcontainer/preconfig/.storage/lovelace | 2 +- README.md | 27 +++++++++++++---------- src/components/live/live.ts | 6 ++--- src/config-mgmt.ts | 6 +++++ src/editor.ts | 4 ++-- src/localize/languages/en.json | 2 +- src/localize/languages/it.json | 2 +- src/localize/languages/pt-BR.json | 2 +- src/types.ts | 2 +- 9 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.devcontainer/preconfig/.storage/lovelace b/.devcontainer/preconfig/.storage/lovelace index 604e5a64..b398e317 100644 --- a/.devcontainer/preconfig/.storage/lovelace +++ b/.devcontainer/preconfig/.storage/lovelace @@ -18,7 +18,7 @@ "cameras": [ { "camera_entity": "camera.big_buck_bunny", - "live_provider": "frigate-jsmpeg", + "live_provider": "jsmpeg", "id": "big_buck_bunny_jsmpeg" }, { diff --git a/README.md b/README.md index b277c6b5..b6003375 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) |`ha` (when configured with LL-HLS)|Better|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency.| |`ha` (Native WebRTC)|Best|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser.| |`image`|Poor|Poor|Best|Builtin|Use refreshing snapshots of the built-in Home Assistant camera streams.| -|`frigate-jsmpeg`|Better|Low|Poor|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| +|`jsmpeg`|Better|Low|Poor|Builtin|Use a the JSMPEG stream.| |`go2rtc`|Best|High|Better|Builtin|Uses [go2rtc](https://github.com/AlexxIT/go2rtc) to stream live feeds. This is supported by Frigate >= `0.12`.| |`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).| @@ -128,11 +128,20 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) #### Available Camera Engines +##### Engine Capabilities + |Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Favorite events|Favorite recordings| | - | - | - | - | - | - | - | - | |`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | |`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | +##### Live providers supported per Engine + +|Engine / Live Provider|`ha`|`image`|`jsmpeg`|`go2rtc`|`webrtc-card`| +| - | - | - | - | - | - | +|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | + #### Camera Frigate configuration @@ -146,7 +155,7 @@ cameras: | Option | Default | Overridable | Description | | - | - | - | - | -| `camera_name` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. To view the birdseye view set this to `birdseye` and use the `frigate-jsmpeg` live provider.| +| `camera_name` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view.| | `url` | | :heavy_multiplication_x: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. All other communication with Frigate goes via Home Assistant. | | `label` | | :heavy_multiplication_x: | A Frigate label / object filter used to filter events (clips & snapshots), e.g. `person`.| | `zone` | | :heavy_multiplication_x: | A Frigate zone used to filter events (clips & snapshots), e.g. `front_door`.| @@ -352,7 +361,7 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. | | `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.| | `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | -| `jsmpeg` | | :white_check_mark: | Configuration for the `frigate-jsmpeg` live provider. See below.| +| `jsmpeg` | | :white_check_mark: | Configuration for the `jsmpeg` live provider. See below.| | `webrtc_card` | | :white_check_mark: | Configuration for the `webrtc-card` live provider. See below.| | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| @@ -2298,7 +2307,7 @@ to provide a separate unambiguous way of referring to that camera, since the type: custom:frigate-card cameras: - camera_entity: camera.front_door - live_provider: frigate-jsmpeg + live_provider: jsmpeg title: Front Door (JSMPEG) - camera_entity: camera.front_door live_provider: webrtc-card @@ -3166,7 +3175,7 @@ Using a `panel` dashboard with the following base configuration will result in t type: custom:frigate-card cameras: - camera_entity: camera.front_door - live_provider: frigate-jsmpeg + live_provider: jsmpeg dimensions: aspect_ratio: 1024:600 aspect_ratio_mode: static @@ -3208,12 +3217,6 @@ For some slowly loading cameras, for which [Home Assistant stream preloading](ht -### JSMPEG Live Camera Only Shows A 'spinner' - -You must be using a version of the [Frigate integration](https://github.com/blakeblackshear/frigate-hass-integration) >= 2.1.0 -to use JSMPEG proxying. The `frigate-jsmpeg` live provider will not work with earlier -integration versions. - ### Timeline shows error message If the timeline shows a message such as `Failed to receive response from Home @@ -3255,7 +3258,7 @@ possible in carousels that use the Firefox video player (e.g. `clips` carousel, or live views that use the `frigate` or `webrtc-card` provider). The next and previous buttons may be used to navigate in these instances. -Dragging works as expected for snapshots, or for the `frigate-jsmpeg` provider. +Dragging works as expected for snapshots, or for the `jsmpeg` provider. ### Progress bar cannot be dragged in Safari diff --git a/src/components/live/live.ts b/src/components/live/live.ts index d0d008f5..1ce25339 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -747,7 +747,7 @@ export class FrigateCardLiveProvider extends LitElement { return 'ha'; } } else if (this.cameraConfig?.frigate.camera_name) { - return 'frigate-jsmpeg'; + return 'jsmpeg'; } return frigateCardConfigDefaults.cameras.live_provider; } @@ -799,7 +799,7 @@ export class FrigateCardLiveProvider extends LitElement { } if (changedProps.has('cameraConfig')) { const provider = this._getResolvedProvider(); - if (provider === 'frigate-jsmpeg') { + if (provider === 'jsmpeg') { import('./live-jsmpeg.js'); } else if (provider === 'ha') { import('./live-ha.js'); @@ -881,7 +881,7 @@ export class FrigateCardLiveProvider extends LitElement { @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` - : provider === 'frigate-jsmpeg' + : provider === 'jsmpeg' ? html` data : {}, ); }, + upgradeArrayValue( + CONF_CAMERAS, + upgradeWithOverrides('live_provider', (val) => + val === 'frigate-jsmpeg' ? 'jsmpeg' : val, + ), + ), ]; diff --git a/src/editor.ts b/src/editor.ts index a5e68f65..58637fb0 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1197,8 +1197,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor label: localize('config.cameras.live_providers.image'), }, { - value: 'frigate-jsmpeg', - label: localize('config.cameras.live_providers.frigate-jsmpeg'), + value: 'jsmpeg', + label: localize('config.cameras.live_providers.jsmpeg'), }, { value: 'go2rtc', diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 42fd0051..d99ec54d 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -38,7 +38,7 @@ "live_provider": "Live view provider for this camera", "live_providers": { "auto": "Automatic", - "frigate-jsmpeg": "Frigate JSMpeg", + "jsmpeg": "JSMpeg", "go2rtc": "go2rtc", "ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC via HA)", "image": "Home Assistant images", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index e5a2f728..613c7779 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -28,7 +28,7 @@ "live_provider": "Provider di visualizzazione dal vivo per questa telecamera", "live_providers": { "auto": "Automatica", - "frigate-jsmpeg": "Frigate JSMpeg", + "jsmpeg": "JSMpeg", "go2rtc": "", "ha": "", "image": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 969c0c47..5db86277 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -28,7 +28,7 @@ "live_provider": "Provedor de visualização ao vivo para esta câmera", "live_providers": { "auto": "Automatico", - "frigate-jsmpeg": "Frigate JSMpeg", + "jsmpeg": "JSMpeg", "go2rtc": "", "ha": "", "image": "", diff --git a/src/types.ts b/src/types.ts index 5270a820..c0d29882 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,7 +70,7 @@ const LIVE_PROVIDERS = [ 'auto', 'image', 'ha', - 'frigate-jsmpeg', + 'jsmpeg', 'go2rtc', 'webrtc-card', ] as const;