Merge pull request #951 from dermotduffy/msertc

Add native `go2rtc` support
This commit is contained in:
Dermot Duffy
2023-02-18 13:15:20 -08:00
committed by GitHub
29 changed files with 1139 additions and 150 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"cameras": [
{
"camera_entity": "camera.big_buck_bunny",
"live_provider": "frigate-jsmpeg",
"live_provider": "jsmpeg",
"id": "big_buck_bunny_jsmpeg"
},
{
+37 -12
View File
@@ -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).|
<a name="engines"></a>
#### 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: |
<a name="camera-frigate-configuration"></a>
#### 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
```
</details>
@@ -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
<a name="jsmpeg-troubleshooting"></a>
### 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
+5 -4
View File
@@ -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;
}
+56 -10
View File
@@ -36,8 +36,10 @@ import {
RecordingSegment,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
CameraURLContext,
CameraEndpointsContext,
CameraConfigs,
CameraEndpoints,
CameraEndpoint,
} from '../types';
import { FrigateRecording } from './types';
import {
@@ -1006,15 +1008,23 @@ export class FrigateCameraManagerEngine
};
}
public getCameraURL(
public getCameraEndpoints(
cameraConfig: CameraConfig,
context?: CameraURLContext,
): string | null {
context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
if (!cameraConfig.frigate.url) {
return null;
}
if (!cameraConfig.frigate.camera_name) {
return cameraConfig.frigate.url;
return { endpoint: cameraConfig.frigate.url };
}
const cameraURL =
`${cameraConfig.frigate.url}/cameras/` + cameraConfig.frigate.camera_name;
if (context?.view === 'live') {
return { endpoint: cameraURL };
}
const eventsURL =
@@ -1027,11 +1037,11 @@ export class FrigateCameraManagerEngine
switch (context?.media?.getMediaType()) {
case 'clip':
case 'snapshot':
return eventsURL;
return { endpoint: eventsURL };
case 'recording':
const startTime = context.media.getStartTime();
if (startTime) {
return recordingsURL + format(startTime, 'yyyy-MM-dd/HH');
return { endpoint: recordingsURL + format(startTime, 'yyyy-MM-dd/HH') };
}
}
@@ -1041,12 +1051,48 @@ export class FrigateCameraManagerEngine
case 'clips':
case 'snapshots':
case 'snapshot':
return eventsURL;
return { endpoint: eventsURL };
case 'recording':
case 'recordings':
return recordingsURL;
return { endpoint: recordingsURL };
}
return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`;
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 }),
}
: null;
}
}
+5 -4
View File
@@ -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;
}
}
+8 -4
View File
@@ -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(
+12 -1
View File
@@ -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<string, CameraConfig>;
// ===========
+13 -8
View File
@@ -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, {
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 }),
...(view && { view: view.view }),
}) ?? null
: null;
}) ?? 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.
+144
View File
@@ -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<void> {
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;
}
}
+2 -2
View File
@@ -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);
}
}
+2 -2
View File
@@ -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);
}
}
+26 -44
View File
@@ -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<string | null> {
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<void> {
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);
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();
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
} else {
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign'));
}
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
}
/**
@@ -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);
}
}
+34 -13
View File
@@ -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 {
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
[this.cameraManager],
() => 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)}
>
</frigate-card-live-ha>`
: provider === 'go2rtc'
? html`<frigate-card-live-go2rtc
${ref(this._providerRef)}
class=${classMap(providerClasses)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-webrtc-card>`
: provider === 'webrtc-card'
? html`<frigate-card-live-webrtc-card
${ref(this._providerRef)}
@@ -861,12 +881,13 @@ export class FrigateCardLiveProvider extends LitElement {
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-webrtc-card>`
: provider === 'frigate-jsmpeg'
: provider === 'jsmpeg'
? html` <frigate-card-live-jsmpeg
${ref(this._providerRef)}
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)}
+6
View File
@@ -735,4 +735,10 @@ const UPGRADES = [
typeof data === 'object' && data ? <RawFrigateCardConfig>data : {},
);
},
upgradeArrayValue(
CONF_CAMERAS,
upgradeWithOverrides('live_provider', (val) =>
val === 'frigate-jsmpeg' ? 'jsmpeg' : val,
),
),
];
+1
View File
@@ -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;
+33 -2
View File
@@ -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,
+9
View File
@@ -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)
+22
View File
@@ -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;
}
+597
View File
@@ -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.<string,Function>}}
*/
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.<string>} 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);
}
}
+14 -3
View File
@@ -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",
+5 -3
View File
@@ -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": "",
+5 -3
View File
@@ -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": "",
+17
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+14 -1
View File
@@ -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(),
+33
View File
@@ -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<string | null> => {
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;
};