Merge pull request #807 from dermotduffy/native-ptz-wo-hover

Add native PTZ element
This commit is contained in:
Dermot Duffy
2022-09-03 15:34:19 -07:00
committed by GitHub
10 changed files with 441 additions and 63 deletions
+85 -45
View File
@@ -746,7 +746,7 @@ All variables listed are under a `conditions:` section.
| `state` | A list of state conditions to compare with Home Assistant state. See below. |
| `mediaLoaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).|
See the [PTZ example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used.
See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used.
### State Conditions
@@ -803,6 +803,7 @@ elements to add special Frigate card functionality.
| `custom:frigate-card-menu-submenu` | Add a configurable submenu dropdown. See [configuration below](#frigate-card-menu-submenu).|
| `custom:frigate-card-menu-submenu-select` | Add a submenu based on a `select` or `input_select`. See [configuration below](#frigate-card-submenu-select).|
| `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).|
| `custom:frigate-card-ptz` | Add a PTZ (Pan Tilt Zoom) controller overlay. See [configuration below](#frigate-card-ptz).|
**Note**: ℹ️ Manual positioning of custom menu icons or submenus via the `style`
@@ -864,6 +865,19 @@ Parameters for the `custom:frigate-card-conditional` element:
`elements` | The elements to render. Can be any supported element, include additional condition or custom elements. |
| `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. See [Frigate Card Conditions](#frigate-card-conditions). |
#### `custom:frigate-card-ptz`
Parameters for the `custom:frigate-card-ptz` element:
| Parameter | Default | Description |
| ------------- | - | -------------------------------------------- |
| `type` | | Must be `custom:frigate-card-ptz`. |
| `style` | `translate(-50%, -50%)` | Position and style the element using CSS. See [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object). |
| `orientation` | `vertical` | Whether to show a `vertical` or `horizontal` PTZ control. |
| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | The [Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) to call when this icon is interacted with. |
| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). |
| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. |
### Special Actions
#### `custom:frigate-card-action`
@@ -1663,6 +1677,31 @@ elements:
state: on
state_not: off
mediaLoaded: true
# Full form PTZ actions (only left button shown).
- type: custom:frigate-card-ptz
orientation: vertical
style:
transform: none
right: 5%
top: 50%
actions_left:
tap_action:
action: call-service
service: sonoff.send_command
service_data:
device: '048123'
cmd: left
# Equivalent short form PTZ actions (only left button shown)
- type: custom:frigate-card-ptz
orientation: vertical
style:
transform: none
right: 20px
top: 180px
service: sonoff.send_command
data_left:
device: '048123'
cmd: left
```
</details>
@@ -2144,12 +2183,12 @@ elements:
### Restricting Icons To Certain Views
You can restrict icons to only show for certain [views](#views) using a
`custom:frigate-card-conditional` element (e.g. PTZ controls)
`custom:frigate-card-conditional` element
<details>
<summary>Expand: View-based conditions (e.g. PTZ controls)</summary>
<summary>Expand: View-based conditions</summary>
This example shows PTZ icons that call a PTZ service, but only in the `live` view.
This example shows a car icon that calls a service but only in the `live` view.
```yaml
[...]
@@ -2160,7 +2199,7 @@ elements:
- live
elements:
- type: icon
icon: mdi:arrow-up
icon: mdi:car
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
@@ -2172,45 +2211,6 @@ elements:
service_data:
entity_id: camera.kitchen
movement: up
- type: icon
icon: mdi:arrow-down
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 25px
bottom: 0px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: down
- type: icon
icon: mdi:arrow-left
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 50px
bottom: 25px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: left
- type: icon
icon: mdi:arrow-right
style:
background: rgba(255, 255, 255, 0.25)
border-radius: 5px
right: 0px
bottom: 25px
tap_action:
action: call-service
service: amcrest.ptz_control
service_data:
entity_id: camera.kitchen
movement: right
```
</details>
@@ -2491,7 +2491,6 @@ overrides:
```
</details>
### Refreshing a static image
<details>
@@ -2532,6 +2531,47 @@ elements:
```
</details>
### Using a PTZ picture element
The card supports a custom PTZ element (`custom:frigate-card-ptz`) to conveniently control pan, tilt and zoom for cameras.
<details>
<summary>Expand: Using the native PTZ picture element</summary>
This example shows the native PTZ element when the `live` or `image` view is displayed and the stream (media) has loaded.
```yaml
[...]
elements:
- type: custom:frigate-card-conditional
conditions:
mediaLoaded: true
view:
- live
- image
elements:
- type: custom:frigate-card-ptz
orientation: horizontal
style:
transform: none
right: 20px
top: 180px
service: sonoff.send_command
data_left:
device: '048123'
cmd: left
data_right:
device: '048123'
cmd: right
data_up:
device: '048123'
cmd: up
data_down:
device: '048123'
cmd: down
```
</details>
<a name="card-updates"></a>
### Using `card-mod` to style the card
+1
View File
@@ -17,6 +17,7 @@ export class CachedValueController<T> implements ReactiveController {
* Remove the controller for the host.
*/
public removeController(): void {
this.stopTimer();
this._host.removeController(this);
}
+16
View File
@@ -1602,6 +1602,10 @@ export class FrigateCard extends LitElement {
if (!this._message || newPriority >= currentPriority) {
this._message = message;
// When a message is displayed it is effectively unloading the media.
this._mediaUnloadedHandler();
if (!skipUpdate) {
this.requestUpdate();
this._resetMainScroll();
@@ -1646,6 +1650,14 @@ export class FrigateCard extends LitElement {
this.requestUpdate();
}
/**
* Unload a media item.
*/
protected _mediaUnloadedHandler(): void {
this._currentMediaLoadedInfo = null;
this._generateConditionState();
}
/**
* Handler called when fullscreen is toggled.
*/
@@ -1672,6 +1684,9 @@ export class FrigateCard extends LitElement {
* Component disconnected callback.
*/
disconnectedCallback(): void {
// When the dashboard 'tab' is changed, the media is effectively unloaded.
this._mediaUnloadedHandler();
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler.bind(this));
}
@@ -1797,6 +1812,7 @@ export class FrigateCard extends LitElement {
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
@frigate-card:media:loaded=${this._mediaLoadedHandler.bind(this)}
@frigate-card:media:unloaded=${this._mediaUnloadedHandler.bind(this)}
@frigate-card:render=${() => this.requestUpdate()}
>
${renderMenuAbove ? this._renderMenu() : ''}
+132 -12
View File
@@ -1,28 +1,39 @@
import { HomeAssistant } from 'custom-card-helpers';
import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
import ptzStyle from '../scss/elements-ptz.scss';
import {
Actions,
ActionsConfig,
FrigateCardError,
FrigateCardPTZConfig,
FrigateConditional,
MenuButton,
MenuIcon,
MenuStateIcon,
MenuSubmenu,
MenuSubmenuSelect,
PictureElements
PictureElements,
} from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
import { actionHandler } from '../action-handler-directive.js';
import {
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
} from '../utils/action.js';
import { classMap } from 'lit/directives/class-map.js';
/* A note on picture element rendering:
*
@@ -343,14 +354,123 @@ export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuI
@customElement('frigate-card-menu-submenu-select')
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon<MenuSubmenuSelect> {}
@customElement('frigate-card-ptz')
export class FrigateCardPTZ extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@state()
protected _config: FrigateCardPTZConfig | null = null;
/**
* Set the card config.
* @param config The configuration.
*/
public setConfig(config: FrigateCardPTZConfig): void {
this._config = config;
}
/**
* Called before each update.
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('_config')) {
this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical');
}
}
/**
* Handle a PTZ action.
* @param ev The actionHandler event.
* @param config The action configuration.
*/
protected _actionHandler(
ev: HASSDomEvent<{ action: string }>,
config?: ActionsConfig,
): void {
// Nothing else has the configuration for this action, so don't let it
// propagate further.
ev.stopPropagation();
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
if (config && action && this.hass) {
frigateCardHandleActionConfig(this, this.hass, config, interaction, action);
}
}
/**
* Render the elements.
* @returns A rendered template or void.
*/
protected render(): TemplateResult | void {
if (!this._config) {
return;
}
const renderIcon = (
name: string,
icon: string,
actions?: Actions,
): TemplateResult => {
const hasHold = frigateCardHasAction(actions?.hold_action);
const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action);
const classes = {
[name]: true,
disabled: !actions,
};
return html`<ha-icon
class=${classMap(classes)}
icon=${icon}
.actionHandler=${actionHandler({
hasHold: hasHold,
hasDoubleClick: hasDoubleClick,
})}
.title=${localize(`elements.ptz.${name}`)}
@action=${(ev) => this._actionHandler(ev, actions)}
></ha-icon>`;
};
return html` <div class="ptz">
<div class="ptz-move">
${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)}
${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)}
${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)}
${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)}
</div>
${this._config.actions_zoom_in || this._config.actions_zoom_out
? html` <div class="ptz-zoom">
${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)}
${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)}
</div>`
: html``}
${this._config.actions_home
? html`
<div class="ptz-home">
${renderIcon('home', 'mdi:home', this._config.actions_home)}
</div>
`
: html``}
</div>`;
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(ptzStyle);
}
}
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
}
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-ptz': FrigateCardPTZ;
}
}
+6 -2
View File
@@ -21,8 +21,9 @@ import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.j
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import { View } from '../view.js';
import { dispatchErrorMessageEvent } from './message.js';
import { contentsChanged } from '../utils/basic.js';
// See: https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py#L101
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@customElement('frigate-card-image')
@@ -36,7 +37,10 @@ export class FrigateCardImage extends LitElement {
@property({ attribute: false })
public cameraConfig?: CameraConfig;
@property({ attribute: false })
// Using contentsChanged to ensure overridden configs (e.g. when the
// 'show_image_during_load' option is true for live views, an overridden
// config may be used here).
@property({ attribute: false, hasChanged: contentsChanged })
public imageConfig?: ImageViewConfig;
protected _refImage: Ref<HTMLImageElement> = createRef();
+12 -4
View File
@@ -106,7 +106,7 @@ export class FrigateCardLive extends LitElement {
// MediaLoadedInfo object and message from the underlying live object. In the
// case of pre-loading these may be propagated upwards later.
protected _savedMediaLoadedInfo: MediaLoadedInfo | null = null;
protected _backgroundMediaLoadedInfo: MediaLoadedInfo | null = null;
protected _messageReceivedPostRender = false;
protected _renderKey = 0;
@@ -127,11 +127,12 @@ export class FrigateCardLive extends LitElement {
if (
!this._inBackground &&
!this._messageReceivedPostRender &&
this._savedMediaLoadedInfo
this._backgroundMediaLoadedInfo
) {
// If this isn't being rendered in the background, the last render did not
// generate a message and there's a saved MediaInfo, dispatch it upwards.
dispatchExistingMediaLoadedInfoAsEvent(this, this._savedMediaLoadedInfo);
dispatchExistingMediaLoadedInfoAsEvent(this, this._backgroundMediaLoadedInfo);
this._backgroundMediaLoadedInfo = null;
}
// Trigger a re-render which may be necessary if the prior render resulted
@@ -223,8 +224,8 @@ export class FrigateCardLive extends LitElement {
}
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
this._savedMediaLoadedInfo = ev.detail;
if (this._inBackground) {
this._backgroundMediaLoadedInfo = ev.detail;
ev.stopPropagation();
}
}}
@@ -735,6 +736,13 @@ export class FrigateCardLiveProvider extends LitElement {
);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._isVideoMediaLoaded = false;
}
/**
* Record that video media is being shown.
*/
+1
View File
@@ -293,6 +293,7 @@ export class FrigateCardMediaCarousel extends LitElement {
this._resizeObserver.disconnect();
this._intersectionObserver.disconnect();
this._mediaLoadedInfo = {};
super.disconnectedCallback();
}
+11
View File
@@ -372,5 +372,16 @@
"no_thumbnail": "No thumbnail available",
"retain_indefinitely": "Event will be indefinitely retained",
"timeline": "See event in timeline"
},
"elements": {
"ptz": {
"up": "Up",
"down": "Down",
"left": "Left",
"right": "Right",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"home": "Home"
}
}
}
+135
View File
@@ -0,0 +1,135 @@
// Modified from / inspired by:
// https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js
:host {
position: relative;
width: fit-content;
height: fit-content;
--frigate-card-ptz-icon-size: 24px;
}
/*****************
* Main Containers
*****************/
.ptz {
display: flex;
gap: 10px;
color: var(--light-primary-color);
opacity: 0.4;
transition: opacity 0.3s ease-in-out;
}
:host([data-orientation='vertical']) .ptz {
flex-direction: column;
}
:host([data-orientation='horizontal']) .ptz {
flex-direction: row;
}
.ptz:hover {
opacity: 1;
}
:host([data-orientation='vertical']) .ptz div {
width: calc(var(--frigate-card-ptz-icon-size) * 3);
}
:host([data-orientation='horizontal']) .ptz div {
height: calc(var(--frigate-card-ptz-icon-size) * 3);
}
.ptz-move,
.ptz-zoom,
.ptz-home {
position: relative;
background-color: rgba(0, 0, 0, 0.3);
}
.ptz-move {
height: calc(var(--frigate-card-ptz-icon-size) * 3);
width: calc(var(--frigate-card-ptz-icon-size) * 3);
border-radius: 50%;
}
:host([data-orientation='horizontal']) .ptz .ptz-zoom,
:host([data-orientation='horizontal']) .ptz .ptz-home {
width: calc(var(--frigate-card-ptz-icon-size) * 1.5);
}
:host([data-orientation='vertical']) .ptz .ptz-zoom,
:host([data-orientation='vertical']) .ptz .ptz-home {
height: calc(var(--frigate-card-ptz-icon-size) * 1.5);
}
.ptz-zoom,
.ptz-home {
border-radius: var(--ha-card-border-radius, 4px);
}
/***********
* PTZ Icons
***********/
ha-icon {
position: absolute;
--mdc-icon-size: var(--frigate-card-ptz-icon-size);
}
ha-icon:not(.disabled) {
cursor: pointer;
}
.disabled {
color: var(--disabled-text-color);
}
.up {
top: 5px;
left: 50%;
transform: translateX(-50%);
}
.down {
bottom: 5px;
left: 50%;
transform: translateX(-50%);
}
.left {
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.right {
right: 5px;
top: 50%;
transform: translateY(-50%);
}
:host([data-orientation='vertical']) .zoom_in {
right: 5px;
top: 50%;
}
:host([data-orientation='vertical']) .zoom_out {
left: 5px;
top: 50%;
}
:host([data-orientation='horizontal']) .zoom_in {
left: 50%;
top: 5px;
}
:host([data-orientation='horizontal']) .zoom_out {
left: 50%;
bottom: 5px;
}
:host([data-orientation='vertical']) .zoom_in,
:host([data-orientation='vertical']) .zoom_out {
transform: translateY(-50%);
}
:host([data-orientation='horizontal']) .zoom_in,
:host([data-orientation='horizontal']) .zoom_out {
transform: translateX(-50%);
}
.home {
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
+42
View File
@@ -499,6 +499,47 @@ const frigateConditionalSchema = z.object({
});
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
const frigateCardPTZSchema = z.preprocess(
// To avoid lots of YAML duplication, provide an easy way to just specify the
// service data as actions for each PTZ icon, and it will be preprocessed into
// the full form. This also provides compatability with the AlexIT/WebRTC PTZ
// configuration.
(data) => {
if (!data || typeof data !== 'object' || !data['service']) {
return data;
}
const out = { ...data };
['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => {
if (`data_${name}` in data && !(`actions_${name}` in data)) {
out[`actions_${name}`] = {
tap_action: {
action: 'call-service',
service: data['service'],
service_data: data[`data_${name}`],
},
};
delete out[`data_${name}`];
}
});
return out;
},
z.object({
type: z.literal('custom:frigate-card-ptz'),
style: z.object({}).passthrough().optional(),
orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(),
service: z.string().optional(),
actions_left: actionsBaseSchema.optional(),
actions_right: actionsBaseSchema.optional(),
actions_up: actionsBaseSchema.optional(),
actions_down: actionsBaseSchema.optional(),
actions_zoom_in: actionsBaseSchema.optional(),
actions_zoom_out: actionsBaseSchema.optional(),
actions_home: actionsBaseSchema.optional(),
}),
);
export type FrigateCardPTZConfig = z.infer<typeof frigateCardPTZSchema>;
// Cannot use discriminatedUnion since customSchema uses a superRefine, which
// causes false rejections.
const pictureElementSchema = z.union([
@@ -507,6 +548,7 @@ const pictureElementSchema = z.union([
menuSubmenuSchema,
menuSubmenuSelectSchema,
frigateConditionalSchema,
frigateCardPTZSchema,
stateBadgeIconSchema,
stateIconSchema,
stateLabelSchema,