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 b6ac4446..b6003375 100644 --- a/README.md +++ b/README.md @@ -120,18 +120,28 @@ 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).| #### 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 @@ -145,12 +155,25 @@ 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`.| | `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. @@ -338,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.| @@ -1357,6 +1380,14 @@ cameras: - binary_sensor.entrance_sensor dependencies: all_cameras: false + - camera_entity: camera.sitting_room + live_provider: go2rtc + go2rtc: + modes: + - webrtc + - mse + - mp4 + - mjpeg ``` @@ -2276,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 @@ -3144,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 @@ -3186,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 @@ -3233,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/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 a39d64ac..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'; @@ -43,7 +44,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 { @@ -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 61ec4c25..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; } /** @@ -1641,6 +1644,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..00fd0c34 --- /dev/null +++ b/src/components/live/live-go2rtc.ts @@ -0,0 +1,144 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import liveMSEStyle from '../../scss/live-go2rtc.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 { 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 +// 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; + + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + + 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) { + return; + } + + const endpoint = this.cameraEndpoints?.go2rtc; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + context: this.cameraConfig, + }); + } + + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + GO2RTC_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { + return; + } + + this._player = new FrigateCardGo2RTCPlayer(); + this._player.src = address; + this._player.visibilityCheck = false; + this._player.background = true; + if (this.cameraConfig?.go2rtc?.modes) { + this._player.mode = this.cameraConfig.go2rtc.modes.join(','); + } + + this.requestUpdate(); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameraEndpoints')) { + 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..55f3ef9c 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -11,22 +11,26 @@ 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 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 { @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}`, - 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(); - }, (URL_SIGN_EXPIRY_SECONDS - 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-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..1ce25339 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} @@ -573,18 +578,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 +610,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()} @@ -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; @@ -739,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; } @@ -791,14 +799,16 @@ 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'); } 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 +860,16 @@ export class FrigateCardLiveProvider extends LitElement { @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` + : provider === 'go2rtc' + ? html` + ` : provider === 'webrtc-card' ? html` ` - : provider === 'frigate-jsmpeg' + : provider === 'jsmpeg' ? html` data : {}, ); }, + upgradeArrayValue( + CONF_CAMERAS, + upgradeWithOverrides('live_provider', (val) => + val === 'frigate-jsmpeg' ? 'jsmpeg' : val, + ), + ), ]; 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 8bb23319..58637fb0 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 @@ -1187,8 +1197,12 @@ 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', + label: localize('config.cameras.live_providers.go2rtc'), }, { value: 'webrtc-card', @@ -1400,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/external/go2rtc/README.md b/src/external/go2rtc/README.md new file mode 100644 index 00000000..1fc4c6bb --- /dev/null +++ b/src/external/go2rtc/README.md @@ -0,0 +1,9 @@ +# go2rtc Player + +**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www + +**Description**: A video player imported from go2rtc. + +**Copyright**: [Alexey Khit](https://github.com/AlexxIT) + +**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE) diff --git a/src/external/go2rtc/video-rtc.d.ts b/src/external/go2rtc/video-rtc.d.ts new file mode 100644 index 00000000..3a0af296 --- /dev/null +++ b/src/external/go2rtc/video-rtc.d.ts @@ -0,0 +1,22 @@ +export class VideoRTC extends HTMLElement { + DISCONNECT_TIMEOUT: number; + RECONNECT_TIMEOUT: number; + CODECS: string[]; + mode: string; + background: boolean; + visibilityThreshold: number; + visibilityCheck: boolean; + pcConfig: RTCConfiguration; + wsState: number; + pcState: number; + video: HTMLVideoElement; + ws: WebSocket | null; + wsURL: string; + pc: RTCPeerConnection; + connectTS: number; + mseCodecs: string; + + src: string | URL; + + oninit(): void; +} diff --git a/src/external/go2rtc/video-rtc.js b/src/external/go2rtc/video-rtc.js new file mode 100644 index 00000000..445aa94d --- /dev/null +++ b/src/external/go2rtc/video-rtc.js @@ -0,0 +1,597 @@ +/** + * Video player for go2rtc streaming application. + * + * All modern web technologies are supported in almost any browser except Apple Safari. + * + * Support: + * - RTCPeerConnection for Safari iOS 11.0+ + * - IntersectionObserver for Safari iOS 12.2+ + * + * Doesn't support: + * - MediaSource for Safari iOS all + * - Customized built-in elements (extends HTMLVideoElement) because all Safari + * - Public class fields because old Safari (before 14.0) + * - Autoplay for Safari + */ +export class VideoRTC extends HTMLElement { + constructor() { + super(); + + this.DISCONNECT_TIMEOUT = 5000; + this.RECONNECT_TIMEOUT = 30000; + + this.CODECS = [ + "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + "avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen) + "avc1.640033", // H.264 high 5.1 (Chromecast with Google TV) + "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra) + "mp4a.40.2", // AAC LC + "mp4a.40.5", // AAC HE + "opus", // OPUS Chrome + ]; + + /** + * [config] Supported modes (webrtc, mse, mp4, mjpeg). + * @type {string} + */ + this.mode = "webrtc,mse,mp4,mjpeg"; + + /** + * [config] Run stream when not displayed on the screen. Default `false`. + * @type {boolean} + */ + this.background = false; + + /** + * [config] Run stream only when player in the viewport. Stop when user scroll out player. + * Value is percentage of visibility from `0` (not visible) to `1` (full visible). + * Default `0` - disable; + * @type {number} + */ + this.visibilityThreshold = 0; + + /** + * [config] Run stream only when browser page on the screen. Stop when user change browser + * tab or minimise browser windows. + * @type {boolean} + */ + this.visibilityCheck = true; + + /** + * [config] WebRTC configuration + * @type {RTCConfiguration} + */ + this.pcConfig = { + iceServers: [{urls: 'stun:stun.l.google.com:19302'}], + sdpSemantics: 'unified-plan', // important for Chromecast 1 + }; + + /** + * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED + * @type {number} + */ + this.wsState = WebSocket.CLOSED; + + /** + * [info] WebRTC connection state. + * @type {number} + */ + this.pcState = WebSocket.CLOSED; + + /** + * @type {HTMLVideoElement} + */ + this.video = null; + + /** + * @type {WebSocket} + */ + this.ws = null; + + /** + * @type {string|URL} + */ + this.wsURL = ""; + + /** + * @type {RTCPeerConnection} + */ + this.pc = null; + + /** + * @type {number} + */ + this.connectTS = 0; + + /** + * @type {string} + */ + this.mseCodecs = ""; + + /** + * [internal] Disconnect TimeoutID. + * @type {number} + */ + this.disconnectTID = 0; + + /** + * [internal] Reconnect TimeoutID. + * @type {number} + */ + this.reconnectTID = 0; + + /** + * [internal] Handler for receiving Binary from WebSocket. + * @type {Function} + */ + this.ondata = null; + + /** + * [internal] Handlers list for receiving JSON from WebSocket + * @type {Object.}} + */ + 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..d99ec54d 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -23,13 +23,24 @@ "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", "live_providers": { "auto": "Automatic", - "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC native)", + "jsmpeg": "JSMpeg", + "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)" }, @@ -352,7 +363,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 1d3204fe..613c7779 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -28,8 +28,10 @@ "live_provider": "Provider di visualizzazione dal vivo per questa telecamera", "live_providers": { "auto": "Automatica", - "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)", + "jsmpeg": "JSMpeg", + "go2rtc": "", + "ha": "", + "image": "", "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" }, "title": "Titolo per questa telecamera (Autoidentificato dall'entità)", @@ -340,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 3af8326e..5db86277 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -28,8 +28,10 @@ "live_provider": "Provedor de visualização ao vivo para esta câmera", "live_providers": { "auto": "Automatico", - "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", + "jsmpeg": "JSMpeg", + "go2rtc": "", + "ha": "", + "image": "", "webrtc-card": "Cartão WebRTC (de @AlexxIT)" }, "title": "Título para esta câmera (detectado automaticamente pela entidade)", @@ -340,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/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..c0d29882 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', + 'jsmpeg', + 'go2rtc', + 'webrtc-card', +] as const; export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; const MEDIA_ACTION_NEGATIVE_CONDITIONS = [ @@ -428,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(), 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; +};