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