diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 2126d4d6..6c8f0761 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -1,12 +1,9 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck - // ==================================================================== // ** Keep modifications to this file to a minimum ** // -// Type checking is disabled since this is a modified copy-and-paste of -// underlying render() function, but the rest of the class source it not -// available as compilation time. +// This is a modified copy-and-paste of the underlying render() function. +// The base class is only registered at runtime, so the members this file +// uses from it are declared in ./types.ts. // ==================================================================== import { @@ -16,8 +13,9 @@ import { unsafeCSS, type CSSResultGroup, type PropertyValues, + type TemplateResult, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js'; import { @@ -28,6 +26,7 @@ import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded- import '../components/image-player.js'; +import type { HomeAssistant } from '../ha/types.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss?inline'; import type { MediaLoadedInfo, @@ -40,6 +39,13 @@ import { onAbort } from '../utils/abort-signal.js'; import './ha-hls-player.js'; import './ha-web-rtc-player.js'; +import type { + AdvancedCameraCardHaCameraStreamElement, + CameraEntity, + ConstructableHaCameraStream, + HaStream, +} from './types.js'; + // A failure reported by one of the inner players. Its existence is the failure; // `error` carries whatever the player knew about it. `dispatched` records // whether it has already been announced, so a stream that fails again after @@ -64,14 +70,18 @@ void customElements.whenDefined('ha-camera-stream').then(() => { const STREAM_TYPE_HLS = 'hls'; const STREAM_TYPE_WEB_RTC = 'web_rtc'; const STREAM_TYPE_MJPEG = 'mjpeg'; - type StreamType = STREAM_TYPE_HLS | STREAM_TYPE_WEB_RTC | STREAM_TYPE_MJPEG; + type StreamType = + | typeof STREAM_TYPE_HLS + | typeof STREAM_TYPE_WEB_RTC + | typeof STREAM_TYPE_MJPEG; + + const HaCameraStream = customElements.get( + 'ha-camera-stream', + ) as ConstructableHaCameraStream; + + class AdvancedCameraCardHaCameraStream extends HaCameraStream implements MediaPlayer { + public declare hass?: HomeAssistant; - @customElement('advanced-camera-card-ha-camera-stream') - // eslint-disable-next-line @typescript-eslint/no-unused-vars - class AdvancedCameraCardHaCameraStream - extends customElements.get('ha-camera-stream') - implements MediaPlayer - { @property({ attribute: false }) public targetID?: string; @@ -80,7 +90,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => { // independently -- we suppress those at this boundary (`stopPropagation` in // `_captureInnerLoad`), cache the latest per type, and republish the // visible one's info via our own source controller in `updated()`. - private _mediaLoadedInfoPerStream: Record = {}; + private _mediaLoadedInfoPerStream: Partial> = {}; private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController( this, { @@ -185,7 +195,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => { this.requestUpdate(); } - protected _renderStream(stream: Stream) { + protected _renderStream(stream: HaStream): TemplateResult | typeof nothing { if (!this.stateObj) { return nothing; } @@ -301,7 +311,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => { // clears their errors), so previously-recorded failures no longer describe // what is playing and must not suppress a fresh one. private _discardErrorsOnEntityChange(changedProps: PropertyValues): void { - const previousStateObj = changedProps.get('stateObj'); + const previousStateObj: CameraEntity | undefined = changedProps.get('stateObj'); if (!previousStateObj || previousStateObj.entity_id === this.stateObj?.entity_id) { return; } @@ -339,10 +349,15 @@ void customElements.whenDefined('ha-camera-stream').then(() => { ]; } } + + customElements.define( + 'advanced-camera-card-ha-camera-stream', + AdvancedCameraCardHaCameraStream, + ); }); declare global { interface HTMLElementTagNameMap { - 'advanced-camera-card-ha-camera-stream': AdvancedCameraCardHaCameraStream; + 'advanced-camera-card-ha-camera-stream': AdvancedCameraCardHaCameraStreamElement; } } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 79972c50..54bbce1e 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -1,12 +1,9 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck - // ==================================================================== // ** Keep modifications to this file to a minimum ** // -// Type checking is disabled since this is a modified copy-and-paste of -// underlying render() function, but the rest of the class source is not -// available as compilation time. +// This is a modified copy-and-paste of the underlying render() function. +// The base class is only registered at runtime, so the members this file +// uses from it are declared in ./types.ts. // ==================================================================== import { @@ -17,7 +14,7 @@ import { type PropertyValues, type TemplateResult, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; @@ -39,25 +36,26 @@ import { dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, } from '../utils/media-info.js'; -import type { ConstructableLitElement } from './types.js'; +import type { + AdvancedCameraCardHaHlsPlayerElement, + ConstructableHaHlsPlayer, +} from './types.js'; void customElements.whenDefined('ha-hls-player').then(() => { - const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement; + const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableHaHlsPlayer; - @customElement('advanced-camera-card-ha-hls-player') - // eslint-disable-next-line @typescript-eslint/no-unused-vars class AdvancedCameraCardHaHlsPlayer extends HaHlsPlayer implements MediaPlayer { // Due to an obscure behavior when this card is casted, this element needs // to use query rather than the ref directive to find the player. @query('#video') - protected _video: HTMLVideoElement; + protected _video?: HTMLVideoElement; @property({ attribute: false }) public targetID?: string; private _mediaPlayerController = new VideoMediaPlayerController( this, - () => this._video, + () => this._video ?? null, () => this.controls, ); @@ -99,14 +97,14 @@ void customElements.whenDefined('ha-hls-player').then(() => { ?playsinline=${this.playsInline} ?controls=${this.controls} @loadedmetadata=${() => { - if (this.controls) { + if (this.controls && this._video) { hideMediaControlsTemporarily( this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, ); } }} - @loadeddata=${(ev) => this._loadedDataHandler(ev)} + @loadeddata=${(ev: Event) => this._loadedDataHandler(ev)} @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} @play=${() => dispatchMediaPlayEvent(this)} @pause=${() => dispatchMediaPauseEvent(this)} @@ -136,13 +134,19 @@ void customElements.whenDefined('ha-hls-player').then(() => { this._lastErrored = errored; } - private _loadedDataHandler(ev: Event) { + private _loadedDataHandler(ev: Event): void { super._loadedData(); + + const video = this._video; + if (!video) { + return; + } + const info = createMediaLoadedInfo(ev, { mediaPlayerController: this._mediaPlayerController, capabilities: { supportsPause: true, - hasAudio: mayHaveAudio(this._video), + hasAudio: mayHaveAudio(video), }, technology: ['hls'], }); @@ -168,10 +172,15 @@ void customElements.whenDefined('ha-hls-player').then(() => { ]; } } + + customElements.define( + 'advanced-camera-card-ha-hls-player', + AdvancedCameraCardHaHlsPlayer, + ); }); declare global { interface HTMLElementTagNameMap { - 'advanced-camera-card-ha-hls-player': AdvancedCameraCardHaHlsPlayer; + 'advanced-camera-card-ha-hls-player': AdvancedCameraCardHaHlsPlayerElement; } } diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 41f3144a..f7687438 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -1,12 +1,9 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck - // ==================================================================== // ** Keep modifications to this file to a minimum ** // -// Type checking is disabled since this is a modified copy-and-paste of -// underlying render() function, but the rest of the class source it not -// available as compilation time. +// This is a modified copy-and-paste of the underlying render() function. +// The base class is only registered at runtime, so the members this file +// uses from it are declared in ./types.ts. // ==================================================================== import { @@ -17,7 +14,7 @@ import { type PropertyValues, type TemplateResult, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js'; @@ -42,15 +39,16 @@ import { dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, } from '../utils/media-info.js'; -import type { ConstructableLitElement } from './types.js'; +import type { + AdvancedCameraCardHaWebRtcPlayerElement, + ConstructableHaWebRtcPlayer, +} from './types.js'; void customElements.whenDefined('ha-web-rtc-player').then(() => { const HaWebRtcPlayer = customElements.get( 'ha-web-rtc-player', - ) as ConstructableLitElement; + ) as ConstructableHaWebRtcPlayer; - @customElement('advanced-camera-card-ha-web-rtc-player') - // eslint-disable-next-line @typescript-eslint/no-unused-vars class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer { @property({ attribute: false }) public targetID?: string; @@ -76,7 +74,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { return this._mediaPlayerController; } - private async _startWebRtc(): Promise { + protected async _startWebRtc(): Promise { // There is a race condition in the underlying HA frontend code between // the element connection and the async start of the WebRTC session. If // the element is rapidly connected and disconnected, the RTC connection @@ -94,7 +92,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { } } - private _addTrack = async (event: RTCTrackEvent) => { + protected _addTrack = async (event: RTCTrackEvent) => { if (!this._remoteStream) { return; } @@ -143,7 +141,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { ); } }} - @loadeddata=${(ev) => this._loadedDataHandler(ev)} + @loadeddata=${(ev: Event) => this._loadedDataHandler(ev)} @volumechange=${() => dispatchMediaVolumeChangeEvent(this)} @play=${() => dispatchMediaPlayEvent(this)} @pause=${() => dispatchMediaPauseEvent(this)} @@ -166,12 +164,12 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { // player can fail more than once and every failure must be reported. const errored = !!this._error; if (errored && !this._lastErrored) { - dispatchLiveErrorEvent(this, { detail: this._error }); + dispatchLiveErrorEvent(this, { description: this._error }); } this._lastErrored = errored; } - private _loadedDataHandler(ev: Event) { + private _loadedDataHandler(ev: Event): void { super._loadedData(); const info = createMediaLoadedInfo(ev, { mediaPlayerController: this._mediaPlayerController, @@ -189,7 +187,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { // capabilities reflect the current state. this._audioTracksMuteStateCleanup?.(); this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener( - this._peerConnection, + this._peerConnection ?? null, () => { const info = createMediaLoadedInfo(this._videoEl, { mediaPlayerController: this._mediaPlayerController, @@ -206,7 +204,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { ); } - private _cleanUp(): void { + protected _cleanUp(): void { super._cleanUp(); this._audioTracksMuteStateCleanup?.(); this._audioTracksMuteStateCleanup = null; @@ -229,10 +227,15 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => { ]; } } + + customElements.define( + 'advanced-camera-card-ha-web-rtc-player', + AdvancedCameraCardHaWebRtcPlayer, + ); }); declare global { interface HTMLElementTagNameMap { - 'advanced-camera-card-ha-web-rtc-player': AdvancedCameraCardHaWebRtcPlayer; + 'advanced-camera-card-ha-web-rtc-player': AdvancedCameraCardHaWebRtcPlayerElement; } } diff --git a/src/patches/types.ts b/src/patches/types.ts index f4fb28ae..a43532f3 100644 --- a/src/patches/types.ts +++ b/src/patches/types.ts @@ -1,5 +1,126 @@ -import type { LitElement } from 'lit'; +import type { HassEntity } from 'home-assistant-js-websocket'; +import type { CSSResultGroup, LitElement, nothing, TemplateResult } from 'lit'; -export interface ConstructableLitElement { - new (...args: unknown[]): LitElement; +import type { HomeAssistant } from '../ha/types.js'; +import type { MediaPlayer } from '../types.js'; + +// The Home Assistant elements the card subclasses are only registered at +// runtime, so their source is unavailable at compilation time. The declarations +// below name the members each patch uses, taken from the Home Assistant +// frontend source linked at the top of each patch. They deliberately describe +// only that subset. + +// A camera entity, whose `access_token` attribute the MJPEG stream URL is built +// from. +export interface CameraEntity extends HassEntity { + attributes: HassEntity['attributes'] & { + access_token?: string; + }; } + +// The stream types Home Assistant can serve from a camera entity. MJPEG is not +// among them: it is the fallback `ha-camera-stream` renders itself. +type HaStreamType = 'hls' | 'web_rtc'; + +// What a player reports about the stream it loaded, on the `streams` event. +interface HaStreamStatus { + hasAudio: boolean; + hasVideo: boolean; +} + +// One of the candidate streams `ha-camera-stream` renders. Only one is visible; +// the rest are rendered hidden so they are ready to be promoted. +export interface HaStream { + type: HaStreamType | 'mjpeg'; + visible: boolean; +} + +// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts +declare class HaHlsPlayerElement extends LitElement { + static override styles: CSSResultGroup; + + entityid?: string; + url?: string; + posterUrl?: string; + controls: boolean; + muted: boolean; + autoPlay: boolean; + playsInline: boolean; + allowExoPlayer: boolean; + + protected _error?: string; + + // Whether `_error` stopped the stream, as opposed to one the player went on + // to recover from by itself. + protected _errorIsFatal: boolean; + + protected _loadedData(): void; +} + +// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts +declare class HaWebRtcPlayerElement extends LitElement { + static override styles: CSSResultGroup; + + entityid?: string; + posterUrl?: string; + controls: boolean; + muted: boolean; + autoPlay: boolean; + playsInline: boolean; + + protected _error?: string; + + protected _videoEl: HTMLVideoElement; + protected _peerConnection?: RTCPeerConnection; + protected _remoteStream?: MediaStream; + + protected _startWebRtc(): Promise; + protected _addTrack: (event: RTCTrackEvent) => Promise; + protected _cleanUp(): void; + protected _loadedData(): void; +} + +// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts +declare class HaCameraStreamElement extends LitElement { + static override styles: CSSResultGroup; + + stateObj?: CameraEntity; + controls: boolean; + muted: boolean; + allowExoPlayer: boolean; + + protected _posterUrl?: string; + protected _connected: boolean; + protected _capabilities?: { frontend_stream_types: HaStreamType[] }; + protected _hlsStreams?: HaStreamStatus; + protected _webRtcStreams?: HaStreamStatus; + + protected _handleHlsStreams(ev: CustomEvent): void; + protected _handleWebRtcStreams(ev: CustomEvent): void; + + // Picks which of the camera's streams to render and which one is visible. + protected _streams( + supportedTypes?: HaStreamType[], + hlsStreams?: HaStreamStatus, + webRtcStreams?: HaStreamStatus, + muted?: boolean, + ): HaStream[]; + + protected _renderStream(stream: HaStream): TemplateResult | typeof nothing; +} + +// `customElements.get()` cannot know which element a tag resolves to, so each +// patch casts its base to the matching constructor. +export type ConstructableHaHlsPlayer = typeof HaHlsPlayerElement; +export type ConstructableHaWebRtcPlayer = typeof HaWebRtcPlayerElement; +export type ConstructableHaCameraStream = typeof HaCameraStreamElement; + +// The elements the card registers: the Home Assistant element plus the card's +// own additions. `hass` is set by the card rather than declared by the Home +// Assistant element, which takes its connection from a context instead. +export type AdvancedCameraCardHaHlsPlayerElement = HaHlsPlayerElement & + MediaPlayer & { hass?: HomeAssistant; targetID?: string }; +export type AdvancedCameraCardHaWebRtcPlayerElement = HaWebRtcPlayerElement & + MediaPlayer & { hass?: HomeAssistant; targetID?: string }; +export type AdvancedCameraCardHaCameraStreamElement = HaCameraStreamElement & + MediaPlayer & { hass?: HomeAssistant; targetID?: string; outputMute: boolean };