Files
advanced-camera-card/src/card.ts
T

520 lines
21 KiB
TypeScript

import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import 'web-dialog';
import { actionHandler } from './action-handler-directive.js';
import { CardController } from './card-controller/controller';
import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js';
import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/effects/effects';
import './components/elements.js';
import { AdvancedCameraCardElements } from './components/elements.js';
import './components/loading.js';
import './components/menu.js';
import { AdvancedCameraCardMenu } from './components/menu.js';
import './components/notification/block.js';
import { renderNotificationBlock } from './components/notification/block.js';
import './components/notification/popup.js';
import './components/overlay.js';
import { AdvancedCameraCardOverlay } from './components/overlay.js';
import './components/status-bar';
import './components/thumbnail-carousel.js';
import './components/views.js';
import { AdvancedCameraCardViews } from './components/views.js';
import { ConditionStateManagerGetEvent } from './conditions/state-manager-via-event.js';
import { StatusBarItem } from './config/schema/actions/types.js';
import { MenuItem } from './config/schema/elements/custom/menu/types.js';
import { AdvancedCameraCardConfig } from './config/schema/types.js';
import { RawAdvancedCameraCardConfig } from './config/types.js';
import { REPO_URL } from './const.js';
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js';
import cardStyle from './scss/card.scss';
import { MediaLoadedInfoEventDetail } from './types.js';
import { hasAction } from './utils/action.js';
import { getReleaseVersion } from './utils/diagnostics';
// ***************************************************************************
// General Card-Wide Notes
// ***************************************************************************
/** Media callbacks:
*
* Media elements (e.g. <video>, <img> or <canvas>) need to callback when:
* - Metadata is loaded / dimensions are known (for aspect-ratio)
* - Media is playing / paused (to avoid reloading)
*
* A number of different approaches used to attach event handlers to
* get these callbacks (which need to be attached directly to the media
* elements, which may be 'buried' down the DOM):
* - Extend the `ha-hls-player` and `ha-camera-stream` to specify the required
* hooks (as querySelecting the media elements after rendering was a fight
* with the Lit rendering engine and was very fragile) .
* - For non-Lit elements (e.g. WebRTC) query selecting after rendering.
* - Library provided hooks (e.g. JSMPEG)
* - Directly specifying hooks (e.g. for snapshot viewing with simple <img> tags)
*/
// ***************************************************************************
// Static Initializers
// ***************************************************************************
console.info(
`%c 📷 Advanced Camera Card %c ${getReleaseVersion()} `,
'padding: 3px; color: black; background: pink;',
'padding: 3px; color: black; background: white;',
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).customCards = (window as any).customCards || [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).customCards.push({
type: 'advanced-camera-card',
name: localize('common.advanced_camera_card'),
description: localize('common.advanced_camera_card_description'),
preview: true,
documentationURL: REPO_URL,
});
// ***************************************************************************
// Main AdvancedCameraCard WebComponent
//
// Any non-rendering / non-lit related functionality should be added to
// CardController instead of this file.
// ***************************************************************************
@customElement('advanced-camera-card')
class AdvancedCameraCard extends LitElement {
protected _controller = new CardController(
this,
// Callback to scroll the main pane back to the top (example usecase: scrolling
// half way down the gallery, then viewing diagnostics should result in
// diagnostics starting at the top).
() => this._refMain.value?.scroll({ top: 0 }),
() => this._refMenu.value?.toggleMenu(),
);
protected _menuButtonController = new MenuButtonController();
protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
protected _refMain: Ref<HTMLElement> = createRef();
protected _refMenu: Ref<AdvancedCameraCardMenu> = createRef();
protected _refOverlay: Ref<AdvancedCameraCardOverlay> = createRef();
protected _refViews: Ref<AdvancedCameraCardViews> = createRef();
// Convenience methods for very frequently accessed attributes.
get _config(): AdvancedCameraCardConfig | null {
return this._controller.getConfigManager().getConfig();
}
get _hass(): HomeAssistant | null {
return this._controller.getHASSManager().getHASS();
}
set hass(hass: HomeAssistant) {
this._controller.getHASSManager().setHASS(hass);
// Manually set hass in the menu, elements and image. This is to allow these
// to update, without necessarily re-rendering the entire card (re-rendering
// is expensive).
if (this._refMenu.value) {
this._refMenu.value.hass = hass;
}
if (this._refElements.value) {
this._refElements.value.hass = hass;
}
if (this._refViews.value) {
this._refViews.value.hass = hass;
}
}
set isPanel(isPanel: boolean) {
this._controller.getConditionStateManager().setState({
panel: isPanel,
});
}
get isPanel(): boolean {
return !!this._controller.getConditionStateManager().getState().panel;
}
public static async getConfigElement(): Promise<LovelaceCardEditor> {
return await CardController.getConfigElement();
}
public static getStubConfig(
_: HomeAssistant,
entities: string[],
): AdvancedCameraCardConfig {
return CardController.getStubConfig(entities);
}
public setConfig(config: RawAdvancedCameraCardConfig): void {
this._controller.getConfigManager().setConfig(config);
}
protected shouldUpdate(): boolean {
// Do not allow a disconnected element to update, as it may cause cameras to
// reinitialize/subscribe for an element that is no longer part of the
// document.
if (!this.isConnected) {
return false;
}
// Always allow blocking issues to render, as they may be generated during
// initialization.
if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) {
return true;
}
if (!this._controller.getInitializationManager().isInitializedMandatory()) {
/* async */ this._controller.getInitializationManager().initializeMandatory();
}
return true;
}
protected _renderMenuStatusContainer(
position: 'top' | 'bottom' | 'overlay',
): TemplateResult | void {
if (!this._config) {
return;
}
const menuStyle = this._config.menu.style;
const menuPosition = this._config.menu.position;
const statusBarStyle = this._config.status_bar.style;
const statusBarPosition = this._config.status_bar.position;
if (
// If there's nothing to render...
(menuStyle === 'none' && statusBarStyle === 'none') ||
// ... or the position I'm rendering does not contain the menu/status bar
(position === 'overlay' &&
menuStyle === 'outside' &&
statusBarStyle === 'outside') ||
(position !== 'overlay' &&
(menuStyle !== 'outside' || menuPosition !== position) &&
(statusBarStyle !== 'outside' || statusBarPosition !== position))
) {
// ... then there's nothing to do.
return;
}
const getContents = (kind: 'overlay' | 'outerlay'): TemplateResult => {
const shouldRenderMenu =
menuStyle !== 'none' &&
((menuStyle === 'outside' && kind === 'outerlay' && menuPosition === position) ||
(menuStyle !== 'outside' && kind === 'overlay'));
const shouldRenderStatusBar =
statusBarStyle !== 'none' &&
((statusBarStyle === 'outside' &&
kind === 'outerlay' &&
statusBarPosition === position) ||
(statusBarStyle !== 'outside' && kind === 'overlay'));
// Complex logic to try to always put the menu in the right-looking place.
const renderMenuFirst =
menuPosition === 'left' ||
menuPosition === 'right' ||
(menuPosition === 'bottom' &&
menuStyle === 'hidden' &&
statusBarStyle !== 'popup') ||
(menuPosition === 'top' && statusBarStyle === 'popup');
return html`
${shouldRenderMenu && renderMenuFirst ? this._renderMenu(menuPosition) : ''}
${shouldRenderStatusBar ? this._renderStatusBar(statusBarPosition) : ''}
${shouldRenderMenu && !renderMenuFirst ? this._renderMenu(menuPosition) : ''}
`;
};
return html`
${position === 'overlay'
? html`<advanced-camera-card-overlay>
${getContents('overlay')}
</advanced-camera-card-overlay>`
: html`<div class="outerlay" data-position="${position}">
${getContents('outerlay')}
</div>`}
`;
}
protected _renderMenu(slot?: string): TemplateResult | void {
const view = this._controller.getViewManager().getView();
if (!this._hass || !this._config) {
return;
}
return html`
<advanced-camera-card-menu
${ref(this._refMenu)}
slot=${ifDefined(slot)}
.hass=${this._hass}
.lockManagerEpoch=${this._controller.getLockManager().getEpoch()}
.menuConfig=${this._config.menu}
.buttons=${this._menuButtonController.calculateButtons(
this._hass,
this._config,
this._controller.getCameraManager(),
this._controller.getFoldersManager(),
{
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
fullscreenManager: this._controller.getFullscreenManager(),
inExpandedMode: this._controller.getExpandManager().isExpanded(),
mediaPlayerController: this._controller.getMediaPlayerManager(),
microphoneManager: this._controller.getMicrophoneManager(),
pipManager: this._controller.getPIPManager(),
showCameraUIButton: this._controller.getCameraURLManager().hasCameraURL(),
view: view,
viewManager: this._controller.getViewManager(),
},
)}
.entityRegistryManager=${this._controller.getEntityRegistryManager()}
></advanced-camera-card-menu>
`;
}
protected _renderStatusBar(slot?: string): TemplateResult | void {
if (!this._config) {
return;
}
// Suppress the status bar entirely while a full-card issue (config
// error, lost connection, failed initialization) is rendering. Those
// take over the card and the status bar would be either redundant
// chrome (showing the engine icon etc. over an error) or physically
// obscuring the notification (in popup/overlay styles).
if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) {
return;
}
return html`
<advanced-camera-card-status-bar
slot=${ifDefined(slot)}
.items=${this._controller.getStatusBarItemManager().calculateItems({
statusConfig: this._config.status_bar,
cameraManager: this._controller.getCameraManager(),
view: this._controller.getViewManager().getView(),
mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
issues: this._controller
.getIssueManager()
.getStateManager()
.getIssueDescriptions(),
})}
.config=${this._config.status_bar}
></advanced-camera-card-status-bar>
`;
}
protected updated(): void {
if (this._controller.getInitializationManager().isInitializedMandatory()) {
this._controller.getQueryStringManager().executeIfNecessary();
}
}
protected _renderInDialogIfNecessary(contents: TemplateResult): TemplateResult | void {
if (this._controller.getExpandManager().isExpanded()) {
return html` <web-dialog
open
center
@close=${() => {
this._controller.getExpandManager().setExpanded(false);
}}
>
${contents}
</web-dialog>`;
} else {
return contents;
}
}
protected render(): TemplateResult | void {
if (!this._hass) {
return;
}
const outerlayUsed =
this._config?.menu.style === 'outside' ||
this._config?.status_bar.style === 'outside';
const mainClasses = {
main: true,
'curve-top':
!outerlayUsed ||
(this._config?.menu.position !== 'top' &&
this._config?.status_bar.position !== 'top'),
'curve-bottom':
!outerlayUsed ||
(this._config?.menu.position !== 'bottom' &&
this._config?.status_bar.position !== 'bottom'),
};
const actions = this._controller.getActionsManager().getMergedActions();
const cameraManager = this._controller.getCameraManager();
const fullCardIssue = this._controller
.getIssueManager()
.getStateManager()
.getFullCardIssue();
const showLoading =
this._config?.performance?.features.card_loading_indicator !== false &&
!fullCardIssue;
// Caution: Keep the main div and the menu next to one another in order to
// ensure the hover menu styling continues to work.
return this._renderInDialogIfNecessary(
html` <advanced-camera-card-effects
.effectsManager=${this._controller.getEffectsManager()}
></advanced-camera-card-effects>
<ha-card
id="ha-card"
.actionHandler=${actionHandler({
hasHold: hasAction(actions.hold_action),
hasDoubleClick: hasAction(actions.double_tap_action),
})}
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
@advanced-camera-card:issue:notify=${(ev: CustomEvent<IssueKey>) =>
this._controller.getIssueManager().showNotification(ev.detail)}
@advanced-camera-card:issue:trigger=${({
detail: { key, ...context },
}: CustomEvent<IssueTriggerEventData>) =>
this._controller.getIssueManager().trigger(key, context)}
@advanced-camera-card:media:loaded=${(
ev: CustomEvent<MediaLoadedInfoEventDetail>,
) => this._controller.getMediaLoadedInfoManager().handleLoadEvent(ev)}
@advanced-camera-card:media:volumechange=${
() => this.requestUpdate() /* Refresh mute menu button */
}
@advanced-camera-card:media:play=${
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:media:pause=${
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:focus=${() => this.focus()}
@advanced-camera-card:notification:dismiss=${() =>
this._controller.getNotificationManager().reset()}
>
${showLoading
? html`<advanced-camera-card-loading
.loaded=${this._controller
.getInitializationManager()
.wasEverInitialized()}
.effectsManager=${this._config?.performance?.features
.card_loading_effects !== false
? this._controller.getEffectsManager()
: undefined}
></advanced-camera-card-loading>`
: ''}
${this._renderMenuStatusContainer('top')}
${this._renderMenuStatusContainer('overlay')}
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
<advanced-camera-card-views
${ref(this._refViews)}
.hass=${this._hass}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.foldersManager=${this._controller.getFoldersManager()}
.viewItemManager=${this._controller.getViewItemManager()}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.config=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()}
.hide=${!!fullCardIssue}
.microphoneManager=${this._controller.getMicrophoneManager()}
.microphoneState=${this._controller.getMicrophoneManager().getState()}
.locked=${this._controller.getLockManager().isLocked()}
.conditionStateManager=${this._controller.getConditionStateManager()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined}
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
.issues=${this._controller
.getIssueManager()
.getStateManager()
.getIssuePresence()}
></advanced-camera-card-views>
${fullCardIssue ? renderNotificationBlock(fullCardIssue.notification) : ''}
</div>
${this._renderMenuStatusContainer('bottom')}
${this._config?.elements
? // Elements need to render after the main views so it can render 'on
// top'.
html` <advanced-camera-card-elements
${ref(this._refElements)}
.hass=${this._hass}
.elements=${this._config?.elements}
.conditionStateManager=${this._controller.getConditionStateManager()}
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.addDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@advanced-camera-card:menu:remove=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.removeDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@advanced-camera-card:status-bar:add=${(
ev: CustomEvent<StatusBarItem>,
) => {
this._controller
.getStatusBarItemManager()
.addDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:status-bar:remove=${(
ev: CustomEvent<StatusBarItem>,
) => {
this._controller
.getStatusBarItemManager()
.removeDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:condition-state-manager:get=${(
ev: ConditionStateManagerGetEvent,
) => {
ev.conditionStateManager = this._controller.getConditionStateManager();
}}
>
</advanced-camera-card-elements>`
: ``}
${this._controller.getNotificationManager().getNotification()
? html`<advanced-camera-card-notification
.notification=${this._controller
.getNotificationManager()
.getNotification()}
></advanced-camera-card-notification>`
: ''}
</ha-card>`,
);
}
static get styles(): CSSResultGroup {
return unsafeCSS(cardStyle);
}
public getCardSize(): number {
// This method is called before the card is rendered. As such, we don't
// actually know what height the card will end up being, and for this card
// it may change significantly with usage. As such, we just return a fixed
// size guess (stock HA cards, such as the picture glance card, do similar).
// Lovelace card size is expressed in units of 50px. A 16:9 aspect-ratio
// camera will likely render as a 276.75px height masonary card => 5.52
// units of 50, round up to 6.
return 6;
}
}
// Keep the old name around for backwards compatibility.
@customElement('frigate-card')
class FrigateCard extends AdvancedCameraCard {}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card': AdvancedCameraCard;
'frigate-card': FrigateCard;
}
}