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": [ "cameras": [
{ {
"camera_entity": "camera.big_buck_bunny", "camera_entity": "camera.big_buck_bunny",
"live_provider": "frigate-jsmpeg", "live_provider": "jsmpeg",
"id": "big_buck_bunny_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` (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.| |`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.| |`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).| |`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> <a name="engines"></a>
#### Available Camera Engines #### Available Camera Engines
##### Engine Capabilities
|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Favorite events|Favorite recordings| |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: | |`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: | |`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> <a name="camera-frigate-configuration"></a>
#### Camera Frigate configuration #### Camera Frigate configuration
@@ -145,12 +155,25 @@ cameras:
| Option | Default | Overridable | Description | | 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. | | `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`.| | `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`.| | `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).| | `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 #### 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. 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. | | `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.| | `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. | | `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.| | `webrtc_card` | | :white_check_mark: | Configuration for the `webrtc-card` live provider. See below.|
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
@@ -1357,6 +1380,14 @@ cameras:
- binary_sensor.entrance_sensor - binary_sensor.entrance_sensor
dependencies: dependencies:
all_cameras: false all_cameras: false
- camera_entity: camera.sitting_room
live_provider: go2rtc
go2rtc:
modes:
- webrtc
- mse
- mp4
- mjpeg
``` ```
</details> </details>
@@ -2276,7 +2307,7 @@ to provide a separate unambiguous way of referring to that camera, since the
type: custom:frigate-card type: custom:frigate-card
cameras: cameras:
- camera_entity: camera.front_door - camera_entity: camera.front_door
live_provider: frigate-jsmpeg live_provider: jsmpeg
title: Front Door (JSMPEG) title: Front Door (JSMPEG)
- camera_entity: camera.front_door - camera_entity: camera.front_door
live_provider: webrtc-card 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 type: custom:frigate-card
cameras: cameras:
- camera_entity: camera.front_door - camera_entity: camera.front_door
live_provider: frigate-jsmpeg live_provider: jsmpeg
dimensions: dimensions:
aspect_ratio: 1024:600 aspect_ratio: 1024:600
aspect_ratio_mode: static 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> <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 ### Timeline shows error message
If the timeline shows a message such as `Failed to receive response from Home 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 or live views that use the `frigate` or `webrtc-card` provider). The next and
previous buttons may be used to navigate in these instances. 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 ### Progress bar cannot be dragged in Safari
+5 -4
View File
@@ -18,9 +18,10 @@ import {
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
CameraURLContext, CameraEndpointsContext,
CameraConfigs, CameraConfigs,
Engine, Engine,
CameraEndpoints,
} from './types'; } from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -118,8 +119,8 @@ export interface CameraManagerEngine {
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
getCameraURL( getCameraEndpoints(
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
context?: CameraURLContext, context?: CameraEndpointsContext,
): string | null; ): CameraEndpoints | null;
} }
+85 -39
View File
@@ -36,8 +36,10 @@ import {
RecordingSegment, RecordingSegment,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraURLContext, CameraEndpointsContext,
CameraConfigs, CameraConfigs,
CameraEndpoints,
CameraEndpoint,
} from '../types'; } from '../types';
import { FrigateRecording } from './types'; import { FrigateRecording } from './types';
import { import {
@@ -1006,47 +1008,91 @@ export class FrigateCameraManagerEngine
}; };
} }
public getCameraURL( public getCameraEndpoints(
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
context?: CameraURLContext, context?: CameraEndpointsContext,
): string | null { ): CameraEndpoints | null {
if (!cameraConfig.frigate.url) { const getUIEndpoint = (): CameraEndpoint | null => {
return null; if (!cameraConfig.frigate.url) {
} return null;
if (!cameraConfig.frigate.camera_name) { }
return cameraConfig.frigate.url; if (!cameraConfig.frigate.camera_name) {
} return { endpoint: cameraConfig.frigate.url };
}
const eventsURL = const cameraURL =
`${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; `${cameraConfig.frigate.url}/cameras/` + 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 if (context?.view === 'live') {
// correct URL. return { endpoint: cameraURL };
switch (context?.media?.getMediaType()) { }
case 'clip':
case 'snapshot': const eventsURL =
return eventsURL; `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name;
case 'recording': const recordingsURL =
const startTime = context.media.getStartTime(); `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name;
if (startTime) {
return recordingsURL + format(startTime, 'yyyy-MM-dd/HH'); // 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 }),
} }
} : null;
// 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}`;
} }
} }
+5 -4
View File
@@ -16,12 +16,13 @@ import {
RecordingQueryResultsMap, RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraURLContext, CameraEndpointsContext,
CameraConfigs, CameraConfigs,
RecordingQuery, RecordingQuery,
QueryReturnType, QueryReturnType,
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
Engine, Engine,
CameraEndpoints,
} from '../types'; } from '../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry'; import { EntityRegistryManager } from '../../utils/ha/entity-registry';
@@ -177,10 +178,10 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null; return null;
} }
public getCameraURL( public getCameraEndpoints(
_cameraConfig: CameraConfig, _cameraConfig: CameraConfig,
_context?: CameraURLContext, _context?: CameraEndpointsContext,
): string | null { ): CameraEndpoints | null {
return null; return null;
} }
} }
+8 -4
View File
@@ -6,7 +6,7 @@ import {
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
CameraManagerCapabilities, CameraManagerCapabilities,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
CameraURLContext, CameraEndpointsContext,
DataQuery, DataQuery,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
@@ -29,6 +29,7 @@ import {
RecordingSegmentsQueryResults, RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
ResultsMap, ResultsMap,
CameraEndpoints,
} from './types.js'; } from './types.js';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js'; import { CameraManagerEngineFactory } from './engine-factory.js';
@@ -43,7 +44,7 @@ import { getCameraID } from '../utils/camera.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { CameraInitializationError } from './error.js'; import { CameraInitializationError } from './error.js';
import { CameraManagerStore } from './store.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'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
class QueryClassifier { 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 cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID); const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) { if (!cameraConfig || !engine) {
return null; return null;
} }
return engine.getCameraURL(cameraConfig, context); return engine.getCameraEndpoints(cameraConfig, context);
} }
public getCameraMetadata( public getCameraMetadata(
+12 -1
View File
@@ -106,11 +106,22 @@ export interface CameraManagerCameraMetadata {
icon: string; icon: string;
} }
export interface CameraURLContext { export interface CameraEndpointsContext {
media?: ViewMedia; media?: ViewMedia;
view?: FrigateCardView; view?: FrigateCardView;
} }
export interface CameraEndpoint {
endpoint: string;
sign?: boolean;
}
export interface CameraEndpoints {
ui?: CameraEndpoint;
go2rtc?: CameraEndpoint;
jsmpeg?: CameraEndpoint;
}
export type CameraConfigs = Map<string, CameraConfig>; export type CameraConfigs = Map<string, CameraConfig>;
// =========== // ===========
+14 -9
View File
@@ -1461,15 +1461,18 @@ class FrigateCard extends LitElement {
* @returns The URL or null if unavailable. * @returns The URL or null if unavailable.
*/ */
protected _getCameraURLFromContext(): string | null { protected _getCameraURLFromContext(): string | null {
const view = this._view; if (!this._view) {
const selectedCameraID = view?.camera; return null;
const media = view?.queryResults?.getSelectedResult() ?? null; }
return this._hass && view && selectedCameraID
? this._cameraManager?.getCameraURL(selectedCameraID, { const selectedCameraID = this._view.camera;
...(media && { media: media }), const media = this._view.queryResults?.getSelectedResult() ?? null;
...(view && { view: view.view }), const endpoints =
}) ?? null this._cameraManager?.getCameraEndpoints(selectedCameraID, {
: null; view: this._view.view,
...(media && { media: media }),
}) ?? null;
return endpoints?.ui?.endpoint ?? null;
} }
/** /**
@@ -1641,6 +1644,8 @@ class FrigateCard extends LitElement {
return; return;
} }
log(this._cardWideConfig, `Frigate Card media load: `, mediaLoadedInfo);
this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo; this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo;
// An update may be required to draw elements. // 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 { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.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 { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js'; import { getStateObjOrDispatchError } from './live.js';
import '../../patches/ha-camera-stream'; import '../../patches/ha-camera-stream';
@@ -83,7 +83,7 @@ export class FrigateCardLiveHA extends LitElement {
* Get styles. * Get styles.
*/ */
static get styles(): CSSResultGroup { 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 { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; 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 { CameraConfig, LiveImageConfig } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js'; import { getStateObjOrDispatchError } from './live.js';
import '../image.js'; import '../image.js';
@@ -84,7 +84,7 @@ export class FrigateCardLiveImage extends LitElement {
* Get styles. * Get styles.
*/ */
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(liveFrigateStyle); return unsafeCSS(liveImageStyle);
} }
} }
+27 -45
View File
@@ -11,22 +11,26 @@ import {
ExtendedHomeAssistant, ExtendedHomeAssistant,
JSMPEGConfig, JSMPEGConfig,
} from '../../types.js'; } from '../../types.js';
import { homeAssistantSignPath } from '../../utils/ha';
import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; import { dispatchMediaLoadedEvent } from '../../utils/media-info.js';
import { dispatchErrorMessageEvent } from '../message.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. // 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. // 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') @customElement('frigate-card-live-jsmpeg')
export class FrigateCardLiveJSMPEG extends LitElement { export class FrigateCardLiveJSMPEG extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameraConfig?: CameraConfig; public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public jsmpegConfig?: JSMPEGConfig; public jsmpegConfig?: JSMPEGConfig;
@@ -83,37 +87,6 @@ export class FrigateCardLiveJSMPEG extends LitElement {
// JSMPEG does not support seeking. // 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. * Create a JSMPEG player.
* @param url The URL for the player to connect to. * @param url The URL for the player to connect to.
@@ -205,26 +178,35 @@ export class FrigateCardLiveJSMPEG extends LitElement {
* Refresh the JSMPEG player. * Refresh the JSMPEG player.
*/ */
protected async _refreshPlayer(): Promise<void> { protected async _refreshPlayer(): Promise<void> {
if (!this.hass) {
return;
}
this._resetPlayer(); this._resetPlayer();
this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement = document.createElement('canvas');
this._jsmpegCanvasElement.className = 'media'; this._jsmpegCanvasElement.className = 'media';
if (!this.cameraConfig?.frigate.camera_name) { const endpoint = this.cameraEndpoints?.jsmpeg;
return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { if (!endpoint) {
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
context: this.cameraConfig, context: this.cameraConfig,
}); });
} }
const url = await this._getURL(); const address = await getEndpointAddressOrDispatchError(
if (url) { this,
this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); this.hass,
this._refreshPlayerTimerID = window.setTimeout(() => { endpoint,
this.requestUpdate(); JSMPEG_URL_SIGN_EXPIRY_SECONDS,
}, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); );
} else { if (!address) {
dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); 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);
} }
/** /**
@@ -3,7 +3,7 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import liveWebRTCStyle from '../../scss/live-webrtc.scss'; import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
import { import {
CameraConfig, CameraConfig,
CardWideConfig, CardWideConfig,
@@ -193,7 +193,7 @@ export class FrigateCardLiveWebRTCCard extends LitElement {
* Get styles. * Get styles.
*/ */
static get styles(): CSSResultGroup { 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 { HomeAssistant } from 'custom-card-helpers';
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
import { HassEntity } from 'home-assistant-js-websocket'; 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 * 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 <frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load} ?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig} .cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
[this.cameraManager],
() => this.cameraManager?.getCameraEndpoints(cameraID),
)}
.label=${cameraMetadata?.title ?? ''} .label=${cameraMetadata?.title ?? ''}
.liveConfig=${config} .liveConfig=${config}
.hass=${this.hass} .hass=${this.hass}
@@ -573,18 +578,16 @@ export class FrigateCardLiveCarousel extends LitElement {
const [prevID, nextID] = this._getCameraIDsOfNeighbors(); const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const cameraMetadataPrevious = prevID ? this.cameraManager.getCameraMetadata( const cameraMetadataPrevious = prevID
this.hass, ? this.cameraManager.getCameraMetadata(this.hass, prevID)
prevID, : null;
) : null;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this.hass, this.hass,
this.view.camera, this.view.camera,
); );
const cameraMetadataNext = nextID ? this.cameraManager.getCameraMetadata( const cameraMetadataNext = nextID
this.hass, ? this.cameraManager.getCameraMetadata(this.hass, nextID)
nextID, : null;
) : null;
// Notes on the below: // Notes on the below:
// - guard() is used to avoid reseting the carousel unless the // - guard() is used to avoid reseting the carousel unless the
@@ -607,7 +610,9 @@ export class FrigateCardLiveCarousel extends LitElement {
[this.cameraManager, this.liveConfig], [this.cameraManager, this.liveConfig],
this._getPlugins.bind(this), this._getPlugins.bind(this),
) as EmblaCarouselPlugins} ) as EmblaCarouselPlugins}
.label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}" .label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.titlePopupConfig=${config.controls.title} .titlePopupConfig=${config.controls.title}
.selected=${this._getSelectedCameraIndex()} .selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()} transitionEffect=${this._getTransitionEffect()}
@@ -666,6 +671,9 @@ export class FrigateCardLiveProvider extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameraConfig?: CameraConfig; public cameraConfig?: CameraConfig;
@property({ attribute: false })
public cameraEndpoints?: CameraEndpoints;
@property({ attribute: false }) @property({ attribute: false })
public liveConfig?: LiveConfig; public liveConfig?: LiveConfig;
@@ -739,7 +747,7 @@ export class FrigateCardLiveProvider extends LitElement {
return 'ha'; return 'ha';
} }
} else if (this.cameraConfig?.frigate.camera_name) { } else if (this.cameraConfig?.frigate.camera_name) {
return 'frigate-jsmpeg'; return 'jsmpeg';
} }
return frigateCardConfigDefaults.cameras.live_provider; return frigateCardConfigDefaults.cameras.live_provider;
} }
@@ -791,14 +799,16 @@ export class FrigateCardLiveProvider extends LitElement {
} }
if (changedProps.has('cameraConfig')) { if (changedProps.has('cameraConfig')) {
const provider = this._getResolvedProvider(); const provider = this._getResolvedProvider();
if (provider === 'frigate-jsmpeg') { if (provider === 'jsmpeg') {
import('./live-jsmpeg.js'); import('./live-jsmpeg.js');
} else if (provider === 'ha') { } else if (provider === 'ha') {
import('./live-ha.js'); import('./live-ha.js');
} else if (provider === 'webrtc-card') { } else if (provider === 'webrtc-card') {
import('./live-webrtc.js'); import('./live-webrtc-card.js');
} else if (provider === 'image') { } else if (provider === 'image') {
import('./live-image.js'); 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:media:loaded=${this._videoMediaShowHandler.bind(this)}
> >
</frigate-card-live-ha>` </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' : provider === 'webrtc-card'
? html`<frigate-card-live-webrtc-card ? html`<frigate-card-live-webrtc-card
${ref(this._providerRef)} ${ref(this._providerRef)}
@@ -861,12 +881,13 @@ export class FrigateCardLiveProvider extends LitElement {
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
> >
</frigate-card-live-webrtc-card>` </frigate-card-live-webrtc-card>`
: provider === 'frigate-jsmpeg' : provider === 'jsmpeg'
? html` <frigate-card-live-jsmpeg ? html` <frigate-card-live-jsmpeg
${ref(this._providerRef)} ${ref(this._providerRef)}
class=${classMap(providerClasses)} class=${classMap(providerClasses)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.jsmpegConfig=${this.liveConfig.jsmpeg} .jsmpegConfig=${this.liveConfig.jsmpeg}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
+6
View File
@@ -735,4 +735,10 @@ const UPGRADES = [
typeof data === 'object' && data ? <RawFrigateCardConfig>data : {}, 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; `${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_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_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_ID = `${CONF_CAMERAS}.#.id` as const;
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` 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_VIEW_UPDATE_SECONDS,
CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE,
MEDIA_CHUNK_SIZE_MAX, MEDIA_CHUNK_SIZE_MAX,
CONF_CAMERAS_ARRAY_GO2RTC_MODES,
} from './const.js'; } from './const.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import frigate_card_editor_style from './scss/editor.scss'; import frigate_card_editor_style from './scss/editor.scss';
@@ -156,6 +157,7 @@ const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_WEBRTC = 'cameras.webrtc'; const MENU_CAMERAS_WEBRTC = 'cameras.webrtc';
const MENU_IMAGE_LAYOUT = 'image.layout'; 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') }, { 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 { public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be // 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 // 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'), label: localize('config.cameras.live_providers.image'),
}, },
{ {
value: 'frigate-jsmpeg', value: 'jsmpeg',
label: localize('config.cameras.live_providers.frigate-jsmpeg'), label: localize('config.cameras.live_providers.jsmpeg'),
},
{
value: 'go2rtc',
label: localize('config.cameras.live_providers.go2rtc'),
}, },
{ {
value: 'webrtc-card', 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( ${this._putInSubmenu(
MENU_CAMERAS_WEBRTC, MENU_CAMERAS_WEBRTC,
cameraIndex, 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", "url": "Frigate server URL",
"zone": "Frigate zone" "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)", "icon": "Icon for this camera (Autodetected from entity)",
"id": "Unique id for this camera in this card", "id": "Unique id for this camera in this card",
"live_provider": "Live view provider for this camera", "live_provider": "Live view provider for this camera",
"live_providers": { "live_providers": {
"auto": "Automatic", "auto": "Automatic",
"frigate-jsmpeg": "Frigate JSMpeg", "jsmpeg": "JSMpeg",
"ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC native)", "go2rtc": "go2rtc",
"ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC via HA)",
"image": "Home Assistant images", "image": "Home Assistant images",
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
}, },
@@ -352,7 +363,7 @@
"invalid_elements_config": "Invalid picture elements configuration", "invalid_elements_config": "Invalid picture elements configuration",
"invalid_response": "Received invalid response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request",
"jsmpeg_no_player": "Could not start JSMPEG player", "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_not_found": "The configured camera_entity was not found",
"live_camera_unavailable": "Camera unavailable", "live_camera_unavailable": "Camera unavailable",
"no_camera_engine": "Could not determine suitable engine for camera", "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_provider": "Provider di visualizzazione dal vivo per questa telecamera",
"live_providers": { "live_providers": {
"auto": "Automatica", "auto": "Automatica",
"frigate-jsmpeg": "Frigate JSMpeg", "jsmpeg": "JSMpeg",
"ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)", "go2rtc": "",
"ha": "",
"image": "",
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
}, },
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)", "title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
@@ -340,7 +342,7 @@
"invalid_elements_config": "Configurazione degli elementi di immagine non valida", "invalid_elements_config": "Configurazione degli elementi di immagine non valida",
"invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta", "invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta",
"jsmpeg_no_player": "Impossibile avviare JSMPEG Player", "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_not_found": "La telecamera configurata non è stata trovata",
"live_camera_unavailable": "Telecamera non disponibile", "live_camera_unavailable": "Telecamera non disponibile",
"no_camera_engine": "", "no_camera_engine": "",
+5 -3
View File
@@ -28,8 +28,10 @@
"live_provider": "Provedor de visualização ao vivo para esta câmera", "live_provider": "Provedor de visualização ao vivo para esta câmera",
"live_providers": { "live_providers": {
"auto": "Automatico", "auto": "Automatico",
"frigate-jsmpeg": "Frigate JSMpeg", "jsmpeg": "JSMpeg",
"ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", "go2rtc": "",
"ha": "",
"image": "",
"webrtc-card": "Cartão WebRTC (de @AlexxIT)" "webrtc-card": "Cartão WebRTC (de @AlexxIT)"
}, },
"title": "Título para esta câmera (detectado automaticamente pela entidade)", "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_elements_config": "Configuração de elementos de imagem inválida",
"invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", "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_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_not_found": "",
"live_camera_unavailable": "", "live_camera_unavailable": "",
"no_camera_engine": "", "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; const FRIGATE_MENU_PRIORITY_DEFAULT = 50;
export const FRIGATE_MENU_PRIORITY_MAX = 100; 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]; export type LiveProvider = (typeof LIVE_PROVIDERS)[number];
const MEDIA_ACTION_NEGATIVE_CONDITIONS = [ const MEDIA_ACTION_NEGATIVE_CONDITIONS = [
@@ -428,6 +435,12 @@ const cameraConfigSchema = z
}) })
.default(cameraConfigDefault.frigate), .default(cameraConfigDefault.frigate),
go2rtc: z
.object({
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array(),
})
.optional(),
// Camera identifiers for WebRTC. // Camera identifiers for WebRTC.
webrtc_card: webrtcCardCameraConfigSchema.optional(), 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;
};