Initial version of status bar.

This commit is contained in:
Dermot Duffy
2024-08-16 20:08:21 -07:00
parent db9303c60d
commit e503f0e429
77 changed files with 3072 additions and 1061 deletions
+107 -52
View File
@@ -20,6 +20,10 @@ import {
MenuSubmenu,
MenuSubmenuSelect,
PictureElements,
StatusBarIcon,
StatusBarImage,
StatusBarItem,
StatusBarString,
} from '../config/types.js';
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
@@ -164,50 +168,66 @@ export class FrigateCardElements extends LitElement {
@property({ attribute: false })
public elements: PictureElements;
protected _boundMenuRemoveHandler = this._menuRemoveHandler.bind(this);
/**
* Handle a picture element to be removed from the menu.
* @param ev The event.
*/
protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchFrigateCardEvent<MenuItem>(this, 'menu-remove', (ev as CustomEvent).detail);
protected _addHandler(
target: EventTarget,
eventName: string,
handler: (ev: Event) => void,
) {
// Ensure listener is only attached 1 time by removing it first.
target.removeEventListener(eventName, handler);
target.addEventListener(eventName, handler);
}
/**
* Handle a picture element to be added to the menu.
* @param ev The event.
*/
protected _menuAddHandler(ev: Event): void {
protected _menuRemoveHandler = (ev: Event): void => {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchFrigateCardEvent<MenuItem>(this, 'menu:remove', (ev as CustomEvent).detail);
};
protected _statusBarRemoveHandler = (ev: Event): void => {
// Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element).
dispatchFrigateCardEvent<StatusBarItem>(
this,
'status-bar:remove',
(ev as CustomEvent).detail,
);
};
protected _menuAddHandler = (ev: Event): void => {
ev = ev as CustomEvent<MenuItem>;
const path = ev.composedPath();
if (!path.length) {
return;
}
this._addHandler(path[0], 'frigate-card:menu:remove', this._menuRemoveHandler);
};
// See 'A note on custom elements' above to explain what's going on here.
// Ensure listener is only attached 1 time by removing it first.
path[0].removeEventListener(
'frigate-card:menu-remove',
this._boundMenuRemoveHandler,
protected _statusBarAddHandler = (ev: Event): void => {
ev = ev as CustomEvent<MenuItem>;
const path = ev.composedPath();
if (!path.length) {
return;
}
this._addHandler(
path[0],
'frigate-card:status-bar:add',
this._statusBarRemoveHandler,
);
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
}
};
connectedCallback(): void {
super.connectedCallback();
// Catch icons being added to the menu (so their removal can be subsequently
// handled).
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
// Catch icons being added to the menu or status-bar (so their removal can
// be subsequently handled).
this.addEventListener('frigate-card:menu:add', this._menuAddHandler);
this.addEventListener('frigate-card:status-bar:add', this._statusBarAddHandler);
}
disconnectedCallback(): void {
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
this.removeEventListener('frigate-card:menu:add', this._menuAddHandler);
this.addEventListener('frigate-card:status-bar:add', this._statusBarAddHandler);
super.disconnectedCallback();
}
@@ -286,59 +306,94 @@ export class FrigateCardElementsConditional extends LitElement {
}
// A base class for rendering menu icons / menu state icons.
export class FrigateCardElementsBaseMenuIcon<T> extends LitElement {
@state()
protected _config: T | null = null;
export class FrigateCardElementsBaseItem<ConfigType> extends LitElement {
protected _eventCategory: string;
/**
* Set the card config.
* @param config The configuration.
*/
public setConfig(config: T): void {
constructor(eventCategory: string) {
super();
this._eventCategory = eventCategory;
}
@state()
protected _config: ConfigType | null = null;
public setConfig(config: ConfigType): void {
this._config = config;
}
/**
* Connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
dispatchFrigateCardEvent<T>(this, 'menu-add', this._config);
dispatchFrigateCardEvent<ConfigType>(
this,
`${this._eventCategory}:add`,
this._config,
);
}
}
/**
* Disconnected callback.
*/
disconnectedCallback(): void {
if (this._config) {
dispatchFrigateCardEvent<T>(this, 'menu-remove', this._config);
dispatchFrigateCardEvent<ConfigType>(
this,
`${this._eventCategory}:remove`,
this._config,
);
}
super.disconnectedCallback();
}
}
export class FrigateCardElementsBaseMenuItem<
ConfigType,
> extends FrigateCardElementsBaseItem<ConfigType> {
constructor() {
super('menu');
}
}
@customElement('frigate-card-menu-icon')
export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon<MenuIcon> {}
export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuItem<MenuIcon> {}
@customElement('frigate-card-menu-state-icon')
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon<MenuStateIcon> {}
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuItem<MenuStateIcon> {}
@customElement('frigate-card-menu-submenu')
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon<MenuSubmenu> {}
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuItem<MenuSubmenu> {}
@customElement('frigate-card-menu-submenu-select')
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon<MenuSubmenuSelect> {}
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuItem<MenuSubmenuSelect> {}
export class FrigateCardElementsBaseStatusBarItem<
ConfigType,
> extends FrigateCardElementsBaseItem<ConfigType> {
constructor() {
super('status-bar');
}
}
@customElement('frigate-card-status-bar-icon')
export class FrigateCardElementsStatusBarIcon extends FrigateCardElementsBaseStatusBarItem<StatusBarIcon> {}
@customElement('frigate-card-status-bar-image')
export class FrigateCardElementsStatusBarImage extends FrigateCardElementsBaseStatusBarItem<StatusBarImage> {}
@customElement('frigate-card-status-bar-string')
export class FrigateCardElementsStatusBarString extends FrigateCardElementsBaseStatusBarItem<StatusBarString> {}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-conditional': FrigateCardElementsConditional;
'frigate-card-elements': FrigateCardElements;
'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect;
'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu;
'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
'frigate-card-elements-core': FrigateCardElementsCore;
'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu;
'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect;
'frigate-card-status-bar-icon': FrigateCardElementsStatusBarIcon;
'frigate-card-status-bar-image': FrigateCardElementsStatusBarImage;
'frigate-card-status-bar-string': FrigateCardElementsStatusBarString;
}
}
+4
View File
@@ -10,6 +10,7 @@ import {
dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent,
} from '../../../utils/media-info';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
/**
* Video player for go2rtc streaming application.
@@ -325,6 +326,7 @@ export class VideoRTC extends HTMLElement {
supportsPause: true,
hasAudio: mayHaveAudio(this.video),
},
technology: getTechnologyForVideoRTC(this),
});
};
this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
@@ -641,6 +643,7 @@ export class VideoRTC extends HTMLElement {
receivedFirstFrame = true;
dispatchMediaLoadedEvent(this, this.video, {
player: this.containingPlayer,
technology: ['mjpeg'],
});
}
};
@@ -668,6 +671,7 @@ export class VideoRTC extends HTMLElement {
dispatchMediaLoadedEvent(this, video2, {
player: this.containingPlayer,
technology: ['mp4'],
});
}
+1
View File
@@ -141,6 +141,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
capabilities: {
supportsPause: true,
},
technology: ['jsmpeg'],
});
}
}
+10 -5
View File
@@ -1,8 +1,9 @@
import { Task } from '@lit-labs/task';
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { Task } from '@lit-labs/task';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { getTechnologyForVideoRTC } from '../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { CameraConfig, CardWideConfig } from '../../config/types.js';
import { localize } from '../../localize/localize.js';
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
@@ -22,6 +23,7 @@ import {
import { screenshotMedia } from '../../utils/screenshot.js';
import { renderTask } from '../../utils/task.js';
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
import { VideoRTC } from './go2rtc/video-rtc.js';
// Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC
@@ -104,15 +106,16 @@ export class FrigateCardLiveWebRTCCard
this.requestUpdate();
}
protected _getVideoRTC(): VideoRTC | null {
return (this.renderRoot?.querySelector('#webrtc') ?? null) as VideoRTC | null;
}
/**
* Get the underlying video player.
* @returns The player or `null` if not found.
*/
protected _getPlayer(): HTMLVideoElement | null {
const root = this.renderRoot?.querySelector('#webrtc') as
| (HTMLElement & { video?: HTMLVideoElement })
| null;
return root?.video ?? null;
return this._getVideoRTC()?.video ?? null;
}
protected async _getWebRTCCardElement(): Promise<
@@ -192,6 +195,7 @@ export class FrigateCardLiveWebRTCCard
// Extract the video component after it has been rendered and generate the
// media load event.
this.updateComplete.then(() => {
const videoRTC = this._getVideoRTC();
const video = this._getPlayer();
if (video) {
setControlsOnVideo(video, this.controls);
@@ -205,6 +209,7 @@ export class FrigateCardLiveWebRTCCard
supportsPause: true,
hasAudio: mayHaveAudio(video),
},
...(videoRTC && { technology: getTechnologyForVideoRTC(videoRTC) }),
});
};
video.onplay = () => dispatchMediaPlayEvent(this);
-29
View File
@@ -62,11 +62,6 @@ import '../next-prev-control.js';
import '../ptz.js';
import { FrigateCardPTZ } from '../ptz.js';
import '../surround.js';
import '../title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
} from '../title-control.js';
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
@@ -294,7 +289,6 @@ export class FrigateCardLiveCarousel extends LitElement {
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _refPTZControl: Ref<FrigateCardPTZ> = createRef();
@state()
@@ -540,18 +534,10 @@ export class FrigateCardLiveCarousel extends LitElement {
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(prevID, view))
: null;
const cameraID = this.viewFilterCameraID ?? view.camera;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this._getSubstreamCameraID(cameraID, view),
);
const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(nextID, view))
: null;
const titleConfig = getDefaultTitleConfigForView(
view,
this.overriddenLiveConfig?.controls.title,
);
// Notes on the below:
// - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change.
@@ -574,9 +560,6 @@ export class FrigateCardLiveCarousel extends LitElement {
transitionEffect=${this._getTransitionEffect()}
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:media:loaded=${() => {
if (this._refTitleControl.value) {
this._refTitleControl.value.show();
}
this._mediaHasLoaded = true;
}}
@frigate-card:media:unloaded=${() => {
@@ -620,18 +603,6 @@ export class FrigateCardLiveCarousel extends LitElement {
.forceVisibility=${this._mediaHasLoaded && view.context?.ptzControls?.enabled}
>
</frigate-card-ptz>
${cameraMetadataCurrent && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
}
+25
View File
@@ -0,0 +1,25 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import overlayStyle from '../scss/overlay.scss';
@customElement('frigate-card-overlay')
export class FrigateCardOverlay extends LitElement {
protected render(): TemplateResult | void {
return html`
<slot name="top"></slot>
<slot name="left"></slot>
<slot name="right"></slot>
<slot name="bottom"></slot>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(overlayStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-overlay': FrigateCardOverlay;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { CSSResultGroup, LitElement, PropertyValues, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { actionHandler } from '../action-handler-directive.js';
import { StatusBarController } from '../components-lib/status-bar-controller';
import { StatusBarConfig, StatusBarItem } from '../config/types';
import statusStyle from '../scss/status.scss';
import { frigateCardHasAction } from '../utils/action';
@customElement('frigate-card-status-bar')
export class FrigateCardStatusBar extends LitElement {
protected _controller = new StatusBarController(this);
@property({ attribute: false })
public items?: StatusBarItem[];
@property({ attribute: false })
public config?: StatusBarConfig;
protected willUpdate(changedProperties: PropertyValues): void {
// Always set config before items.
if (changedProperties.has('config') && this.config) {
this._controller.setConfig(this.config);
}
if (changedProperties.has('items')) {
this._controller.setItems(this.items ?? []);
}
}
protected render(): TemplateResult | void {
if (!this._controller.shouldRender()) {
return;
}
return html`
<div class="status">
${this._controller.getRenderItems().map((item): TemplateResult | void => {
if (item.enabled === false) {
return;
}
const classes = classMap({
item: true,
expand: !!item.expand,
action: !!Object.keys(item.actions ?? {}).length,
});
const handler = actionHandler({
hasHold: frigateCardHasAction(item.actions?.hold_action),
hasDoubleClick: frigateCardHasAction(item.actions?.double_tap_action),
});
if (item.type === 'custom:frigate-card-status-bar-string') {
return html`<div
.actionHandler=${handler}
class="${classes}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
>
${item.string}
</div>`;
} else if (item.type === 'custom:frigate-card-status-bar-icon') {
return html`<ha-icon
.actionHandler=${handler}
class="${classes}"
icon="${item.icon}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
></ha-icon>`;
} else if (item.type === 'custom:frigate-card-status-bar-image') {
return html`<img
.actionHandler=${handler}
class="${classes}"
src="${item.image}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
/>`;
}
})}
</div>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(statusStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-status-bar': FrigateCardStatusBar;
}
}
+5 -2
View File
@@ -81,6 +81,7 @@ export class FrigateCardSubmenu extends LitElement {
if (!this.submenu) {
return html``;
}
const items = this.submenu.items as MenuSubmenuItem[];
return html`
<ha-button-menu
corner=${'BOTTOM_LEFT'}
@@ -109,7 +110,7 @@ export class FrigateCardSubmenu extends LitElement {
>
<ha-icon icon="${this.submenu.icon}"></ha-icon>
</ha-icon-button>
${this.submenu.items.map(this._renderItem.bind(this))}
${items.map(this._renderItem.bind(this))}
</ha-button-menu>
`;
}
@@ -217,9 +218,11 @@ export class FrigateCardSubmenuSelect extends LitElement {
// the items correctly (below).
delete submenu['options'];
const items = submenu.items as MenuSubmenuItem[];
for (const option of options) {
const title = this._optionTitles?.[option] ?? option;
submenu.items.push({
items.push({
state_color: true,
selected: stateObj.state === option,
enabled: true,
-4
View File
@@ -51,10 +51,6 @@ export class FrigateCardSurroundBasic extends LitElement {
}
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
return html` <slot name="above"></slot>
<slot></slot>
-98
View File
@@ -1,98 +0,0 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { TitleControlConfig } from '../config/types';
import titleStyle from '../scss/title-control.scss';
import { View } from '../view/view.js';
type PaperToast = HTMLElement & {
opened: boolean;
};
export const getDefaultTitleConfigForView = (
view?: Readonly<View> | null,
baseConfig?: TitleControlConfig,
): TitleControlConfig | null => {
if (!baseConfig && view?.isGrid()) {
return { mode: 'none', duration_seconds: 2 };
}
return {
mode: 'popup-bottom-right',
duration_seconds: 2,
...baseConfig,
};
};
@customElement('frigate-card-title-control')
export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false })
public config?: TitleControlConfig;
@property({ attribute: false })
public text?: string;
@property({ attribute: false })
public fitInto?: HTMLElement;
@property({ attribute: false })
public logo?: string;
protected _toastRef: Ref<PaperToast> = createRef();
protected render(): TemplateResult {
if (
!this.text ||
!this.config ||
!this.config.mode ||
this.config.duration_seconds === undefined ||
this.config.mode === 'none' ||
!this.fitInto
) {
return html``;
}
const verticalAlign = this.config.mode.match(/-top-/) ? 'top' : 'bottom';
const horizontalAlign = this.config.mode.match(/-left$/) ? 'left' : 'right';
return html` <paper-toast
${ref(this._toastRef)}
class="capsule"
.duration=${this.config.duration_seconds * 1000}
.verticalAlign=${verticalAlign}
.horizontalAlign=${horizontalAlign}
.text="${this.text}"
.fitInto=${this.fitInto}
>
${this.logo ? html`<img src=${this.logo} />` : ''}
</paper-toast>`;
}
public isVisible(): boolean {
return this._toastRef.value?.opened ?? false;
}
public hide(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false;
}
}
public show(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false;
this._toastRef.value.opened = true;
}
}
static get styles(): CSSResultGroup {
return unsafeCSS(titleStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-title-control': FrigateCardTitleControl;
}
}
+2 -28
View File
@@ -73,11 +73,6 @@ import type { EmblaCarouselPlugins } from './carousel.js';
import './next-prev-control.js';
import './ptz';
import './surround.js';
import './title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
} from './title-control.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -194,7 +189,6 @@ export class FrigateCardViewerCarousel extends LitElement {
protected _selected = 0;
protected _media: ViewMedia[] | null = null;
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _player: FrigateCardMediaPlayer | null = null;
/**
@@ -407,15 +401,7 @@ export class FrigateCardViewerCarousel extends LitElement {
}
};
const cameraMetadata = this.cameraManager.getCameraMetadata(
selectedMedia.getCameraID(),
);
const view = this.viewManagerEpoch?.manager.getView();
const titleConfig = getDefaultTitleConfigForView(
view,
this.viewerConfig?.controls.title,
);
return html`
<frigate-card-carousel
@@ -427,9 +413,6 @@ export class FrigateCardViewerCarousel extends LitElement {
this._setViewSelectedIndex(ev.detail.index);
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (this._refTitleControl.value) {
this._refTitleControl.value.show();
}
this._player = ev.detail.player ?? null;
this._seekHandler();
}}
@@ -480,16 +463,6 @@ export class FrigateCardViewerCarousel extends LitElement {
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
</ha-icon>
</div>
${cameraMetadata && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${selectedMedia.getTitle() ?? undefined}"
.logo="${cameraMetadata?.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
}
@@ -919,6 +892,7 @@ export class FrigateCardViewerProvider
supportsPause: true,
hasAudio: mayHaveAudio(ev.target as HTMLVideoElement),
},
technology: ['hls'],
});
}}
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
@@ -942,7 +916,7 @@ export class FrigateCardViewerProvider
}
}}
@load=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev, { player: this });
dispatchMediaLoadedEvent(this, ev, { player: this, technology: ['jpg'] });
}}
/>`}
`);