Files
advanced-camera-card/src/components/live/providers/webrtc-card.ts
T

248 lines
8.2 KiB
TypeScript

import { Task } from '@lit-labs/task';
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Camera } from '../../../camera-manager/camera.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import type { Notification } from '../../../config/schema/actions/types.js';
import type { CardWideConfig } from '../../../config/schema/types.js';
import type { HomeAssistant } from '../../../ha/types.js';
import { localize } from '../../../localize/localize.js';
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
import {
AdvancedCameraCardError,
type MediaPlayer,
type MediaPlayerController,
} from '../../../types.js';
import { mayHaveAudio } from '../../../utils/audio.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/controls.js';
import { getContextFromError } from '../../../utils/error-context.js';
import {
createMediaLoadedInfo,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
} from '../../../utils/media-info.js';
import { renderTask } from '../../../utils/task.js';
import '../../notification/block.js';
import { renderNotificationBlock } from '../../notification/block.js';
import '../../progress-indicator.js';
import { renderProgressIndicator } from '../../progress-indicator.js';
import type { VideoRTC } from './go2rtc/video-rtc.js';
// Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC
@customElement('advanced-camera-card-live-webrtc-card')
export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements MediaPlayer {
@property({ attribute: false })
public camera?: Camera;
// The BASE camera ID (camera property may be a substream)
@property({ attribute: false })
public targetID?: string;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: true, type: Boolean })
public controls = false;
@state()
private _notification: Notification | null = null;
private hass?: HomeAssistant;
private _videoRTC: VideoRTC | null = null;
private _mediaPlayerController = new VideoMediaPlayerController(
this,
() => this._getVideo(),
() => this.controls,
);
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
getTargetID: () => this.targetID ?? null,
});
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
return this._mediaPlayerController;
}
// A task to await the load of the WebRTC component.
private _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]);
connectedCallback(): void {
super.connectedCallback();
// Reset the player when reconnected to the DOM.
// https://github.com/dermotduffy/advanced-camera-card/issues/996
this.requestUpdate();
}
disconnectedCallback(): void {
this._videoRTC = null;
this._notification = null;
super.disconnectedCallback();
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('camera')) {
this._notification = null;
}
}
/**
* Get the underlying video player.
* @returns The player or `null` if not found.
*/
private _getVideo(): HTMLVideoElement | null {
return this._videoRTC?.video ?? null;
}
private async _getWebRTCCardElement(): Promise<CustomElementConstructor | undefined> {
await customElements.whenDefined('webrtc-camera');
return customElements.get('webrtc-camera');
}
/**
* Create the WebRTC element. May throw.
*/
private _createWebRTC(): HTMLElement | null {
const webrtcElement = this._webrtcTask.value;
const cameraConfig = this.camera?.getConfig();
if (webrtcElement && this.hass && cameraConfig) {
const webrtc = new webrtcElement() as HTMLElement & {
hass: HomeAssistant;
setConfig: (config: Record<string, unknown>) => void;
};
const config = {
// By default, webrtc-card will stop the video when 50% of the video is
// hidden. This is incompatible with the card zoom support, since the
// video will easily stop if the user zooms in too much. Disable this
// feature by default.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1614
intersection: 0,
// Advanced Camera Card always starts muted (unlike webrtc-card).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1654
muted: true,
...cameraConfig.webrtc_card,
};
const webrtcCardEndpoint = this.camera?.getEndpoints()?.webrtcCard;
if (!config.url && !config.entity && webrtcCardEndpoint) {
config.entity = webrtcCardEndpoint.endpoint;
}
webrtc.setConfig(config);
webrtc.hass = this.hass;
return webrtc;
}
return null;
}
protected render(): TemplateResult | void {
if (this._notification) {
return renderNotificationBlock(this._notification);
}
const render = (): TemplateResult | void => {
let webrtcElement: HTMLElement | null;
try {
webrtcElement = this._createWebRTC();
} catch (e) {
const context = getContextFromError(e);
this._notification = createNotificationFromText(
e instanceof AdvancedCameraCardError
? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
{
...(context && { context }),
},
);
dispatchLiveErrorEvent(this);
return;
}
if (webrtcElement) {
// Set the id to ensure that the relevant CSS styles will have
// sufficient specifity to overcome some styles that are otherwise
// applied to <ha-card> in Safari.
webrtcElement.id = 'webrtc';
}
return html`${webrtcElement}`;
};
// Use a task to allow us to asynchronously wait for the WebRTC card to
// load, but yet still have the card load be followed by the updated()
// lifecycle callback (unlike just using `until`).
return renderTask(this._webrtcTask, render, {
inProgressFunc: () =>
renderProgressIndicator({
message: localize('error.webrtc_card_waiting'),
cardWideConfig: this.cardWideConfig,
}),
});
}
public updated(): void {
// Extract the video component after it has been rendered and generate the
// media load event.
void this.updateComplete.then(() => {
this._videoRTC = this.renderRoot?.querySelector('#webrtc') ?? null;
const video = this._getVideo();
if (video) {
setControlsOnVideo(video, this.controls);
video.onloadeddata = () => {
if (this.controls) {
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}
const info = createMediaLoadedInfo(video, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
supportsPause: true,
hasAudio: mayHaveAudio(video),
},
...(this._videoRTC && {
technology: getTechnologyForVideoRTC(this._videoRTC),
}),
});
if (info) {
this._mediaLoadedInfoSourceController.set(info);
}
};
video.onplay = () => dispatchMediaPlayEvent(this);
video.onpause = () => dispatchMediaPauseEvent(this);
video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
}
});
}
static get styles(): CSSResultGroup {
return unsafeCSS(liveWebRTCCardStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-live-webrtc-card': AdvancedCameraCardLiveWebRTCCard;
}
}