feat: Allow user generated notifications (#2401)

This commit is contained in:
Dermot Duffy
2026-03-07 20:47:24 -08:00
committed by GitHub
parent 1d81e03b04
commit 24c6b98948
53 changed files with 939 additions and 471 deletions
+16
View File
@@ -67,3 +67,19 @@
- **Think through UX before implementing.** For any user-visible change, reason through the full set of states and edge cases it touches. Flag them before writing code, not after.
- **Be concise.** Short, direct answers are preferred. Skip preamble, avoid restating the question, and don't summarize what you just did unless asked.
- **No re-exports or pass-through files.** Have callers import directly from the source module. Don't create files that only re-export from another file.
- **Single source of truth for types.** When a Zod schema defines a shape, derive the TypeScript type from it (`z.infer<typeof schema>`). Don't maintain a parallel interface that duplicates the schema.
- **Separate external API from internal extensions.** User-facing schemas should only contain user-configurable fields. For internal-only fields, create a derived interface (e.g. `interface InternalFoo extends Foo { internalField?: ... }`). Never expose internal plumbing in external schemas.
- **Extract shared schemas eagerly.** If a schema (e.g. `iconSchema`) could apply to multiple features, put it in `config/schema/common/` from the start rather than nesting it under a specific feature.
- **Name fields for their semantic purpose.** Choose names that reflect what the field means in context. Ask yourself what the field actually does.
- **Accurate comment headings.** If a comment section covers multiple related items, the heading must reflect all of them — not just the first one added.
- **Check CSS inheritance before adding interactive elements.** Parent rules like `pointer-events: none` silently block children. When adding clickable items to a container, verify the full CSS cascade allows interaction.
- **Preserve alphabetical ordering.** When inserting into a list, file, or set of sections that is fully or mostly alphabetized, maintain that ordering.
@@ -341,6 +341,52 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: mute
```
## `notification`
[](../../common/experimental-warning.md ':include')
Show a notification overlay on the card with optional controls.
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: notification
# [...]
```
| Parameter | Description |
| ----------------------------- | --------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `notification`. |
| `notification` | The notification to display. See below. |
### `notification`
| Parameter | Description |
| ---------- | ------------------------------------------------------------------------------------- |
| `heading` | An optional heading. Uses the same format as [`details`](README.md?id=details) below. |
| `text` | An optional text string to display as the notification body. |
| `details` | An optional list of metadata. See [`details`](README.md?id=details) below. |
| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. |
### Details
| Parameter | Description |
| ---------- | ------------------------------------------------------------- |
| `text` | The detail text. |
| `icon` | An optional icon (e.g. `mdi:alert`). |
| `tooltip` | An optional tooltip string (shown on hover). |
| `severity` | An optional severity level, one of `low`, `medium` or `high`. |
### Controls
| Parameter | Default | Description |
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `icon` | | An optional icon (e.g. `mdi:check-circle`). |
| `tooltip` | | An optional tooltip string (shown on hover). |
| `severity` | | An optional severity level, one of `low`, `medium` or `high`. |
| `actions` | | [Actions](../README.md) to execute on interaction. Supports `tap_action`, `hold_action`, `double_tap_action`, `start_tap_action`, `end_tap_action`. See [example](../../../examples.md?id=notifications). |
| `dismiss` | `true` | Whether to dismiss the notification after the control's actions execute. |
## `pause`
Pause the selected media.
@@ -740,6 +786,29 @@ elements:
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: info
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-m-circle-outline
title: Show notification
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: notification
notification:
heading:
text: Attention
icon: mdi:alert
severity: high
text: Something important happened.
controls:
- icon: mdi:filmstrip
tooltip: View clips (tap) / View clip (double-tap)
actions:
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: clips
double_tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: clip
dismiss: false
- type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-n-circle
title: Turn off substream
@@ -154,6 +154,7 @@ elements:
| `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. |
| `icon` | | The icon to show in the status bar, e.g. `mdi:camera-front`. See also [custom icons](../../../usage/custom-icons.md). |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
| `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. |
| `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). |
## `status-bar-image`
@@ -175,6 +176,7 @@ elements:
| `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. |
| `image` | | The image to show in the status bar, e.g. `https://my.site.com/status.png`. |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
| `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. |
| `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). |
## `status-bar-string`
@@ -196,6 +198,7 @@ elements:
| `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. |
| `string` | | The string to show in the status bar, e.g. `Intruder detected!` |
| `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. |
| `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. |
| `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). |
## Fully expanded reference
@@ -331,6 +334,7 @@ elements:
expand: false
string: 'Intruder alert!'
priority: 50
severity: high
sufficient: false
- type: custom:advanced-camera-card-status-bar-icon
enabled: true
@@ -338,6 +342,7 @@ elements:
expand: false
icon: 'mdi:cow'
priority: 50
severity: medium
sufficient: false
- type: custom:advanced-camera-card-status-bar-image
enabled: true
@@ -345,5 +350,6 @@ elements:
expand: false
image: https://my.site.com/status.png
priority: 50
severity: low
sufficient: false
```
+4
View File
@@ -35,6 +35,7 @@ status_bar:
| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
| `technology` | The detected media technology (if any). |
| `title` | The media title. |
| `upgrade` | An indicator that appears when a configuration upgrade is available. |
### Options for each item
@@ -85,4 +86,7 @@ status_bar:
title:
enabled: true
priority: 50
upgrade:
enabled: true
priority: 50
```
+36
View File
@@ -729,6 +729,42 @@ cameras:
id: office-webrtc
```
## Notifications
Show a notification with controls that respond to different interactions. In
this example, tapping the control navigates to the `clips` (media gallery) view
and double-tapping navigates to the `clip` (media player) view.
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.office
elements:
- type: custom:advanced-camera-card-menu-icon
icon: mdi:bell
title: Show notification
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: notification
notification:
heading:
text: Motion detected
icon: mdi:motion-sensor
severity: medium
text: Motion was detected in the office.
controls:
- icon: mdi:filmstrip
tooltip: View clips (tap) / clip (double-tap)
actions:
tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: clips
double_tap_action:
action: custom:advanced-camera-card-action
advanced_camera_card_action: clip
dismiss: false
```
## Overriding configuration
You can override card configuration when certain [conditions](configuration/conditions.md) are met.
+3 -2
View File
@@ -1,8 +1,9 @@
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { InternalIcon } from '../config/schema/common/icon';
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
import { CapabilityKey, Endpoint, Icon } from '../types';
import { CapabilityKey, Endpoint } from '../types';
import { ViewMedia } from '../view/item';
// ====
@@ -118,7 +119,7 @@ export interface CapabilitySearchOptions {
export interface CameraManagerCameraMetadata {
title: string;
icon: Icon;
icon: InternalIcon;
// Engine icon is just a string since it will never be entity-derived.
engineIcon?: string;
+2 -2
View File
@@ -16,8 +16,8 @@ export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
const controller = new MediaDetailsController();
controller.calculate(api.getCameraManager(), item);
api.getOverlayMessageManager().setMessage(
controller.getMessage({
api.getNotificationManager().setNotification(
controller.getNotification({
hass: api.getHASSManager().getHASS() ?? undefined,
viewItemManager: api.getViewItemManager(),
viewManagerEpoch: api.getViewManager().getEpoch(),
@@ -0,0 +1,10 @@
import { NotificationActionConfig } from '../../../config/schema/actions/types';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class NotificationAction extends AdvancedCameraCardAction<NotificationActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getNotificationManager().setNotification(this._action.notification);
}
}
+3
View File
@@ -25,6 +25,7 @@ import { MoreInfoAction } from './actions/more-info';
import { MuteAction } from './actions/mute';
import { NavigateAction } from './actions/navigate';
import { NoneAction } from './actions/none';
import { NotificationAction } from './actions/notification';
import { PauseAction } from './actions/pause';
import { PerformActionAction } from './actions/perform-action';
import { PIPAction } from './actions/pip';
@@ -163,6 +164,8 @@ export class ActionFactory {
return new PTZControlsAction(context, action, options?.config);
case 'log':
return new LogAction(context, action, options?.config);
case 'notification':
return new NotificationAction(context, action, options?.config);
case 'status_bar':
return new StatusBarAction(context, action, options?.config);
case 'reload':
+8 -2
View File
@@ -28,6 +28,7 @@ export class ConfigManager {
private _overriddenConfig: AdvancedCameraCardConfig | null = null;
private _rawConfig: RawAdvancedCameraCardConfig | null = null;
private _cardWideConfig: CardWideConfig | null = null;
private _upgradeable = false;
private _overridesManager = new OverridesManager(() => this._processOverrideConfig());
constructor(api: CardConfigAPI) {
@@ -54,17 +55,22 @@ export class ConfigManager {
return this._rawConfig;
}
public isUpgradeable(): boolean {
return this._upgradeable;
}
public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void {
if (!inputConfig) {
throw new Error(localize('error.invalid_configuration'));
}
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
this._upgradeable = isConfigUpgradeable(inputConfig);
if (!parseResult.success) {
const configUpgradeable = isConfigUpgradeable(inputConfig);
const hint = getParseError(parseResult.error);
let upgradeMessage = '';
if (configUpgradeable) {
if (this._upgradeable) {
upgradeMessage = `${localize('error.upgrade_available')}. `;
}
throw new Error(
+6 -6
View File
@@ -31,7 +31,7 @@ import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { OverlayMessageManager } from './overlay-message-manager';
import { NotificationManager } from './notification-manager';
import { PIPManager } from './pip-manager';
import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager';
@@ -58,7 +58,7 @@ import {
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardOverlayMessageAPI,
CardNotificationAPI,
CardPIPAPI,
CardQueryStringAPI,
CardStyleAPI,
@@ -90,7 +90,7 @@ export class CardController
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardOverlayMessageAPI,
CardNotificationAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
@@ -125,7 +125,7 @@ export class CardController
private _mediaPlayerManager = new MediaPlayerManager(this);
private _messageManager = new MessageManager(this);
private _microphoneManager = new MicrophoneManager(this);
private _overlayMessageManager = new OverlayMessageManager(this);
private _notificationManager = new NotificationManager(this);
private _pipManager = new PIPManager(this);
private _queryStringManager = new QueryStringManager(this);
private _statusBarItemManager = new StatusBarItemManager(this);
@@ -252,8 +252,8 @@ export class CardController
this._microphoneManager = new MicrophoneManager(this);
}
public getOverlayMessageManager(): OverlayMessageManager {
return this._overlayMessageManager;
public getNotificationManager(): NotificationManager {
return this._notificationManager;
}
public getPIPManager(): PIPManager {
@@ -0,0 +1,33 @@
import { Notification } from '../config/schema/actions/types';
import { CardNotificationAPI } from './types';
export class NotificationManager {
private _notification: Notification | null = null;
private _api: CardNotificationAPI;
constructor(api: CardNotificationAPI) {
this._api = api;
}
public getNotification(): Notification | null {
return this._notification;
}
public hasNotification(): boolean {
return this._notification !== null;
}
// Also used to replace the current notification in-place (e.g. to refresh
// control state after a toggle action).
public setNotification(notification: Notification): void {
this._notification = notification;
this._api.getCardElementManager().update();
}
public reset(): void {
if (this._notification) {
this._notification = null;
this._api.getCardElementManager().update();
}
}
}
@@ -1,31 +0,0 @@
import { OverlayMessage } from '../types';
import { CardOverlayMessageAPI } from './types';
export class OverlayMessageManager {
private _message: OverlayMessage | null = null;
private _api: CardOverlayMessageAPI;
constructor(api: CardOverlayMessageAPI) {
this._api = api;
}
public getMessage(): OverlayMessage | null {
return this._message;
}
public hasMessage(): boolean {
return this._message !== null;
}
public setMessage(message: OverlayMessage): void {
this._message = message;
this._api.getCardElementManager().update();
}
public reset(): void {
if (this._message) {
this._message = null;
this._api.getCardElementManager().update();
}
}
}
@@ -2,7 +2,9 @@ import { isEqual } from 'lodash-es';
import { CameraManager } from '../camera-manager/manager';
import { StatusBarItem } from '../config/schema/actions/types';
import { StatusBarConfig } from '../config/schema/status-bar';
import { localize } from '../localize/localize';
import { MediaLoadedInfo } from '../types';
import { createNotificationAction } from '../utils/action';
import { View } from '../view/view';
import { CardStatusBarAPI } from './types';
@@ -42,6 +44,7 @@ export class StatusBarItemManager {
cameraManager?: CameraManager | null;
view?: View | null;
mediaLoadedInfo?: MediaLoadedInfo | null;
isUpgradeable?: boolean;
}): StatusBarItem[] {
const cameraMetadata = options?.view?.camera
? options?.cameraManager?.getCameraMetadata(options.view.camera)
@@ -124,6 +127,27 @@ export class StatusBarItemManager {
},
]
: []),
...(options?.isUpgradeable
? [
{
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: 'mdi:update',
severity: 'medium' as const,
actions: {
tap_action: createNotificationAction({
heading: {
text: localize('notification.upgrade.heading'),
icon: 'mdi:update',
severity: 'medium',
},
text: localize('notification.upgrade.text'),
}),
},
...options?.statusConfig?.items.upgrade,
},
]
: []),
...this._dynamicItems,
];
}
+3 -3
View File
@@ -22,7 +22,7 @@ import type { MediaLoadedInfoManager } from './media-info-manager';
import type { MediaPlayerManager } from './media-player-manager';
import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { OverlayMessageManager } from './overlay-message-manager';
import type { NotificationManager } from './notification-manager';
import type { PIPManager } from './pip-manager';
import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
@@ -54,7 +54,7 @@ export interface CardActionsAPI {
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getOverlayMessageManager(): OverlayMessageManager;
getNotificationManager(): NotificationManager;
getPIPManager(): PIPManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
@@ -257,7 +257,7 @@ export interface CardMessageAPI {
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
}
export interface CardOverlayMessageAPI {
export interface CardNotificationAPI {
getCardElementManager(): CardElementManager;
}
+11 -11
View File
@@ -16,7 +16,7 @@ import './components/menu.js';
import { AdvancedCameraCardMenu } from './components/menu.js';
import './components/message.js';
import { renderMessage } from './components/message.js';
import './components/overlay-message.js';
import './components/notification.js';
import './components/overlay.js';
import { AdvancedCameraCardOverlay } from './components/overlay.js';
import './components/status-bar';
@@ -32,7 +32,7 @@ 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 { MediaLoadedInfo, Message, OverlayMessage } from './types.js';
import { MediaLoadedInfo, Message } from './types.js';
import { hasAction } from './utils/action.js';
import { getReleaseVersion } from './utils/diagnostics';
@@ -288,6 +288,7 @@ class AdvancedCameraCard extends LitElement {
cameraManager: this._controller.getCameraManager(),
view: this._controller.getViewManager().getView(),
mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
isUpgradeable: this._controller.getConfigManager().isUpgradeable(),
})}
.config=${this._config.status_bar}
></advanced-camera-card-status-bar>
@@ -374,11 +375,8 @@ class AdvancedCameraCard extends LitElement {
() => this.requestUpdate() /* Refresh play/pause menu button */
}
@advanced-camera-card:focus=${() => this.focus()}
@advanced-camera-card:overlay-message:show=${(
ev: CustomEvent<OverlayMessage>,
) => this._controller.getOverlayMessageManager().setMessage(ev.detail)}
@advanced-camera-card:overlay-message:dismiss=${() =>
this._controller.getOverlayMessageManager().reset()}
@advanced-camera-card:notification:dismiss=${() =>
this._controller.getNotificationManager().reset()}
>
${showLoading
? html`<advanced-camera-card-loading
@@ -459,10 +457,12 @@ class AdvancedCameraCard extends LitElement {
>
</advanced-camera-card-elements>`
: ``}
${this._controller.getOverlayMessageManager().getMessage()
? html`<advanced-camera-card-overlay-message
.message=${this._controller.getOverlayMessageManager().getMessage()}
></advanced-camera-card-overlay-message>`
${this._controller.getNotificationManager().getNotification()
? html`<advanced-camera-card-notification
.notification=${this._controller
.getNotificationManager()
.getNotification()}
></advanced-camera-card-notification>`
: ''}
</ha-card>`,
);
+1 -1
View File
@@ -3,9 +3,9 @@ import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg';
import { Icon } from '../config/schema/common/icon';
import { HomeAssistant } from '../ha/types';
import irisSVG from '../images/iris.svg';
import { Icon } from '../types';
export class IconController {
public getCustomIcon(icon?: Icon): string | null {
+85 -71
View File
@@ -3,9 +3,14 @@ import { CameraManager } from '../../camera-manager/manager';
import { CameraManagerCameraMetadata } from '../../camera-manager/types';
import { ViewItemManager } from '../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../card-controller/view/types';
import {
Notification,
NotificationControl,
NotificationDetail,
} from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types';
import { createInternalCallbackAction } from '../../utils/action';
import { getDurationString, prettifyTitle } from '../../utils/basic';
import {
downloadMedia,
@@ -17,7 +22,7 @@ import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types';
export interface OverlayControlsContext {
export interface NotificationControlsContext {
hass?: HomeAssistant;
viewItemManager?: ViewItemManager;
viewManagerEpoch?: ViewManagerEpoch;
@@ -29,8 +34,8 @@ export interface OverlayControlsContext {
}
export class MediaDetailsController {
private _details: MetadataField[] = [];
private _heading: MetadataField | null = null;
private _details: NotificationDetail[] = [];
private _heading: NotificationDetail | null = null;
private _item: ViewItem | null = null;
public calculate(
@@ -61,7 +66,7 @@ export class MediaDetailsController {
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
this._heading = whatWithTags
? { title: `${whatWithTags}${score ? ` ${score}` : ''}` }
? { text: `${whatWithTags}${score ? ` ${score}` : ''}` }
: null;
return;
}
@@ -72,13 +77,13 @@ export class MediaDetailsController {
this._heading = title
? {
title: title,
emphasis: severity ?? undefined,
hint:
text: title,
severity: severity ?? undefined,
tooltip:
localize('common.severity') +
': ' +
localize('common.severities.' + severity),
icon: { icon: 'mdi:circle-medium' },
icon: 'mdi:circle-medium',
}
: null;
return;
@@ -86,7 +91,7 @@ export class MediaDetailsController {
if (cameraMetadata?.title) {
this._heading = {
title: cameraMetadata.title,
text: cameraMetadata.title,
};
return;
}
@@ -121,54 +126,54 @@ export class MediaDetailsController {
...(startTime
? [
{
hint: localize('thumbnail.start'),
icon: { icon: 'mdi:calendar-clock-outline' },
title: format(startTime, 'yyyy-MM-dd HH:mm:ss'),
tooltip: localize('thumbnail.start'),
icon: 'mdi:calendar-clock-outline',
text: format(startTime, 'yyyy-MM-dd HH:mm:ss'),
},
]
: []),
...(duration || inProgress
? [
{
hint: localize('thumbnail.duration'),
icon: { icon: 'mdi:clock-outline' },
title: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`,
tooltip: localize('thumbnail.duration'),
icon: 'mdi:clock-outline',
text: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`,
},
]
: []),
...(cameraMetadata?.title
? [
{
hint: localize('thumbnail.camera'),
title: cameraMetadata.title,
icon: { icon: 'mdi:cctv' },
tooltip: localize('thumbnail.camera'),
text: cameraMetadata.title,
icon: 'mdi:cctv',
},
]
: []),
...(where
? [
{
hint: localize('thumbnail.where'),
title: where,
icon: { icon: 'mdi:map-marker-outline' },
tooltip: localize('thumbnail.where'),
text: where,
icon: 'mdi:map-marker-outline',
},
]
: []),
...(tags
? [
{
hint: localize('thumbnail.tag'),
title: tags,
icon: { icon: 'mdi:tag' },
tooltip: localize('thumbnail.tag'),
text: tags,
icon: 'mdi:tag',
},
]
: []),
...(seekString
? [
{
hint: localize('thumbnail.seek'),
title: seekString,
icon: { icon: 'mdi:clock-fast' },
tooltip: localize('thumbnail.seek'),
text: seekString,
icon: 'mdi:clock-fast',
},
]
: []),
@@ -183,10 +188,10 @@ export class MediaDetailsController {
...(includeTitle && itemTitle
? [
{
title: itemTitle,
text: itemTitle,
...(details.length > 0 && {
icon: { icon: 'mdi:rename' },
hint: localize('thumbnail.title'),
icon: 'mdi:rename',
tooltip: localize('thumbnail.title'),
}),
},
]
@@ -195,20 +200,15 @@ export class MediaDetailsController {
];
}
public getHeading(): MetadataField | null {
public getHeading(): NotificationDetail | null {
return this._heading;
}
public getDetails(): MetadataField[] {
public getDetails(): NotificationDetail[] {
return this._details;
}
/**
* Get an overlay message for the item.
* @param context Optional context to include controls.
* @returns An OverlayMessage.
*/
public getMessage(context?: OverlayControlsContext): OverlayMessage {
public getNotification(context?: NotificationControlsContext): Notification {
return {
heading: this._heading ?? undefined,
controls: context ? this._getControls(context) : undefined,
@@ -219,8 +219,8 @@ export class MediaDetailsController {
};
}
private _getControls(context: OverlayControlsContext): OverlayMessageControl[] {
const controls: OverlayMessageControl[] = [];
private _getControls(context: NotificationControlsContext): NotificationControl[] {
const controls: NotificationControl[] = [];
const item = this._item;
if (!item) {
@@ -230,57 +230,71 @@ export class MediaDetailsController {
if (ViewItemClassifier.isReview(item)) {
const isReviewed = item.isReviewed();
controls.push({
title: isReviewed
tooltip: isReviewed
? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed'),
icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' },
callback: async () => {
const success = await toggleReviewed(
item,
context.viewItemManager,
context.viewManagerEpoch,
context.filterReviewed,
);
return success ? this.getMessage(context) : null;
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
actions: {
tap_action: createInternalCallbackAction(async (api) => {
const success = await toggleReviewed(
item,
context.viewItemManager,
context.viewManagerEpoch,
context.filterReviewed,
);
if (success) {
api
.getNotificationManager()
.setNotification(this.getNotification(context));
}
}),
},
dismiss: false,
});
}
if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) {
const isFavorite = item.isFavorite();
controls.push({
title: localize('thumbnail.retain_indefinitely'),
icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' },
emphasis: isFavorite ? 'medium' : undefined,
callback: async () => {
const success = await toggleFavorite(item, context.viewItemManager);
return success ? this.getMessage(context) : null;
tooltip: localize('thumbnail.retain_indefinitely'),
icon: isFavorite ? 'mdi:star' : 'mdi:star-outline',
severity: isFavorite ? 'medium' : undefined,
actions: {
tap_action: createInternalCallbackAction(async (api) => {
const success = await toggleFavorite(item, context.viewItemManager);
if (success) {
api
.getNotificationManager()
.setNotification(this.getNotification(context));
}
}),
},
dismiss: false,
});
}
if (context.capabilities?.canDownload && item.getID()) {
controls.push({
title: localize('thumbnail.download'),
icon: { icon: 'mdi:download' },
callback: async () => {
await downloadMedia(item, context.viewItemManager);
// Close overlay message after download.
return null;
tooltip: localize('thumbnail.download'),
icon: 'mdi:download',
dismiss: true,
actions: {
tap_action: createInternalCallbackAction(async () => {
await downloadMedia(item, context.viewItemManager);
}),
},
});
}
if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) {
controls.push({
title: localize('thumbnail.timeline'),
icon: { icon: 'mdi:target' },
callback: () => {
navigateToTimeline(item, context.viewManagerEpoch);
// Close overlay after timeline navigation
return null;
tooltip: localize('thumbnail.timeline'),
icon: 'mdi:target',
dismiss: true,
actions: {
tap_action: createInternalCallbackAction(async () => {
navigateToTimeline(item, context.viewManagerEpoch);
}),
},
});
}
+2 -2
View File
@@ -8,9 +8,9 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { IconController } from '../components-lib/icon-controller';
import { InternalIcon } from '../config/schema/common/icon.js';
import { HomeAssistant } from '../ha/types';
import iconStyle from '../scss/icon.scss';
import { Icon } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
@customElement('advanced-camera-card-icon')
@@ -19,7 +19,7 @@ export class AdvancedCameraCardIcon extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false, hasChanged: contentsChanged })
public icon?: Icon;
public icon?: InternalIcon;
// Note: This attribute will allow non-active entity state styles (e.g. 'off',
// 'unavailable') to be overriden from outside the icon itself. This is useful
+1 -1
View File
@@ -2,9 +2,9 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { NextPreviousControlConfig } from '../config/schema/common/controls/next-previous.js';
import { Icon } from '../config/schema/common/icon.js';
import { HomeAssistant } from '../ha/types.js';
import controlStyle from '../scss/next-previous-control.scss';
import { Icon } from '../types.js';
import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
@@ -2,17 +2,29 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import overlayMessageStyle from '../scss/overlay-message.scss';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js';
import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js';
import { actionHandler } from '../action-handler-directive.js';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
import {
Notification,
NotificationControl,
NotificationDetail,
} from '../config/schema/actions/types.js';
import notificationStyle from '../scss/notification.scss';
import {
getActionConfigGivenAction,
hasAction,
stopEventFromActivatingCardWideActions,
} from '../utils/action.js';
import { arrayify } from '../utils/basic.js';
import { dispatchDismissNotificationEvent } from '../utils/notification.js';
import './icon.js';
@customElement('advanced-camera-card-overlay-message')
export class AdvancedCameraCardOverlayMessage extends LitElement {
@customElement('advanced-camera-card-notification')
export class AdvancedCameraCardNotification extends LitElement {
@property({ attribute: false })
public message: OverlayMessage | null = null;
public notification: Notification | null = null;
private _refMessage: Ref<HTMLElement> = createRef();
private _refNotification: Ref<HTMLElement> = createRef();
public connectedCallback(): void {
super.connectedCallback();
@@ -29,20 +41,20 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
}
protected render(): TemplateResult | void {
if (!this.message) {
if (!this.notification) {
return;
}
const heading = this.message.heading;
const details = this.message.details ?? [];
const text = this.message.text;
const controls = this.message.controls ?? [];
const heading = this.notification.heading;
const details = this.notification.details ?? [];
const text = this.notification.text;
const controls = this.notification.controls ?? [];
return html`
<div class="backdrop" @click=${this._dismiss}></div>
<div
class="message"
${ref(this._refMessage)}
class="notification"
${ref(this._refNotification)}
@animationend=${this._handleAnimationEnd}
>
<div class="details">
@@ -64,60 +76,70 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
`;
}
private _renderControl(control: OverlayMessageControl): TemplateResult {
const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : '';
private _renderControl(control: NotificationControl): TemplateResult {
const severityClass = control.severity ? `severity-${control.severity}` : '';
return html`
<div
class="control ${emphasisClass}"
title=${control.title}
@click=${async () => this._handleControlClick(control)}
class="control ${severityClass}"
title=${control.tooltip ?? ''}
.actionHandler=${actionHandler({
hasHold: hasAction(control.actions?.hold_action),
hasDoubleClick: hasAction(control.actions?.double_tap_action),
})}
@action=${(ev: CustomEvent) => this._handleControlAction(ev, control)}
>
${control.icon
? html`<advanced-camera-card-icon
.icon=${control.icon}
.icon=${{ icon: control.icon }}
></advanced-camera-card-icon>`
: ''}
</div>
`;
}
private async _handleControlClick(control: OverlayMessageControl): Promise<void> {
const result = await control.callback();
if (result === null) {
// null = close the message
private _handleControlAction(
ev: CustomEvent<{ action: string }>,
control: NotificationControl,
): void {
stopEventFromActivatingCardWideActions(ev);
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
if (action) {
dispatchActionExecutionRequest(this, {
actions: arrayify(action),
});
}
if (control.dismiss !== false) {
this._dismiss();
} else {
// Updated message = keep open and refresh
this.message = result;
}
}
private _renderDetail(detail: MetadataField, isHeading = false): TemplateResult {
private _renderDetail(detail: NotificationDetail, isHeading = false): TemplateResult {
const classes = {
detail: true,
heading: isHeading,
[`emphasis-${detail.emphasis}`]: !!detail.emphasis,
[`severity-${detail.severity}`]: !!detail.severity,
};
return html`
<div class="${classMap(classes)}">
${detail.icon
? html`<advanced-camera-card-icon
title=${detail.hint ?? ''}
.icon=${detail.icon}
title=${detail.tooltip ?? ''}
.icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
<span title=${detail.text}>${detail.text}</span>
</div>
`;
}
private _dismiss = (): void => {
this._refMessage.value?.classList.add('exiting');
this._refNotification.value?.classList.add('exiting');
};
private _handleAnimationEnd = (ev: AnimationEvent): void => {
if (ev.animationName === 'slideDown') {
dispatchDismissOverlayMessageEvent(this);
dispatchDismissNotificationEvent(this);
}
};
@@ -136,12 +158,12 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
};
static get styles(): CSSResultGroup {
return unsafeCSS(overlayMessageStyle);
return unsafeCSS(notificationStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage;
'advanced-camera-card-notification': AdvancedCameraCardNotification;
}
}
+2 -2
View File
@@ -8,6 +8,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { InternalIcon } from '../../config/schema/common/icon.js';
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { computeDomain } from '../../ha/compute-domain.js';
@@ -17,7 +18,6 @@ import { isHassDifferent } from '../../ha/is-hass-different.js';
import { EntityRegistryManager } from '../../ha/registry/entity/types.js';
import { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js';
import { createSelectOptionAction } from '../../utils/action.js';
import '../icon.js';
import './index.js';
@@ -37,7 +37,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
private _optionTitles?: Record<string, string>;
private _generatedSubmenuItems?: MenuSubmenuItem[];
private _generatedIcon?: Icon;
private _generatedIcon?: InternalIcon;
protected shouldUpdate(changedProps: PropertyValues): boolean {
// No need to update the submenu unless the select entity has changed.
+10 -6
View File
@@ -8,11 +8,12 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager';
import { MediaDetailsController } from '../../components-lib/media/details-controller';
import { NotificationDetail } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
import { MetadataField } from '../../types';
import { ViewItem } from '../../view/item';
import '../icon';
@@ -42,7 +43,10 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
const heading = this._controller.getHeading();
const details = this._controller.getDetails();
const renderDetail = (detail: MetadataField, heading = false): TemplateResult => {
const renderDetail = (
detail: NotificationDetail,
heading = false,
): TemplateResult => {
return html`<div
class=${classMap({
heading,
@@ -50,12 +54,12 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
>
${detail.icon
? html` <advanced-camera-card-icon
severity=${detail.emphasis}
title=${detail.hint ?? ''}
.icon=${detail.icon}
severity=${ifDefined(detail.severity)}
title=${detail.tooltip ?? ''}
.icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>`
: ''}
<span title=${detail.title}>${detail.title}</span>
<span title=${detail.text}>${detail.text}</span>
</div>`;
};
+14 -8
View File
@@ -9,24 +9,27 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../../camera-manager/manager';
import { dispatchActionExecutionRequest } from '../../../card-controller/actions/utils/execution-request';
import { ViewItemManager } from '../../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
NotificationControlsContext,
} from '../../../components-lib/media/details-controller';
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
import { stopEventFromActivatingCardWideActions } from '../../../utils/action';
import {
createNotificationAction,
stopEventFromActivatingCardWideActions,
} from '../../../utils/action';
import {
downloadMedia,
navigateToTimeline,
toggleFavorite,
toggleReviewed,
} from '../../../utils/media-actions';
import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message';
import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier';
import '../../icon.js';
@@ -80,7 +83,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
}
}
private _getControlContext(): OverlayControlsContext {
private _getControlContext(): NotificationControlsContext {
return {
hass: this.hass,
viewItemManager: this.viewItemManager,
@@ -207,10 +210,13 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
stopEventFromActivatingCardWideActions(ev);
const detailsController = new MediaDetailsController();
detailsController.calculate(this.cameraManager, this.item);
dispatchShowOverlayMessageEvent(
this,
detailsController.getMessage(this._getControlContext()),
);
dispatchActionExecutionRequest(this, {
actions: [
createNotificationAction(
detailsController.getNotification(this._getControlContext()),
),
],
});
}}
></advanced-camera-card-icon>`
: ''}
+53 -2
View File
@@ -19,9 +19,26 @@ import { substreamSelectActionConfigSchema } from './custom/substream-select';
import { viewActionConfigSchema } from './custom/view';
import { stockActionSchema } from './stock/types';
// Provide a manual type definition to avoid the `any` that would be created by
// the lazy() evaluation below.
// ============================================================================
// Notification and Status Bar action schemas are co-located here because their
// content schemas reference actionConfigSchema (creating a circular dep).
// Each uses z.lazy + a manual type annotation to break the cycle and preserve
// correct type inference.
// See: https://zod.dev/?id=recursive-types
// ============================================================================
export type NotificationActionConfig = z.infer<
typeof advancedCameraCardCustomActionsBaseSchema
> & {
advanced_camera_card_action: 'notification';
notification: Notification;
};
export const notificationActionConfigSchema: z.ZodSchema<NotificationActionConfig> =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('notification'),
notification: z.lazy(() => notificationSchema),
});
export type StatusBarActionConfig = z.infer<
typeof advancedCameraCardCustomActionsBaseSchema
> & {
@@ -46,6 +63,7 @@ const advancedCameraCardCustomActionSchema = z.union([
internalCallbackActionConfigSchema,
logActionConfigSchema,
mediaPlayerActionConfigSchema,
notificationActionConfigSchema,
ptzActionConfigSchema,
ptzControlsActionConfigSchema,
ptzDigitalActionConfigSchema,
@@ -66,6 +84,7 @@ export const actionConfigSchema = z.union([
advancedCameraCardCustomActionSchema,
]);
export type ActionConfig = z.infer<typeof actionConfigSchema>;
export const actionsBaseSchema = z
.object({
tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
@@ -90,6 +109,38 @@ export const actionsSchema = z.object({
actions: actionsBaseSchema.optional(),
});
// ============================================================================
// Notification Elements
//
// Note: Notification schemas are defined here (after actionsBaseSchema) so
// controls can directly reference actionsBaseSchema without z.lazy.
// ============================================================================
const notificationBaseSchema = z.object({
icon: z.string().optional(),
tooltip: z.string().optional(),
severity: severitySchema.optional(),
});
export const notificationDetailSchema = notificationBaseSchema.extend({
text: z.string(),
});
export type NotificationDetail = z.infer<typeof notificationDetailSchema>;
export const notificationControlSchema = notificationBaseSchema.extend({
actions: actionsBaseSchema.optional(),
dismiss: z.boolean().default(true),
});
export type NotificationControl = z.infer<typeof notificationControlSchema>;
export const notificationSchema = z.object({
heading: notificationDetailSchema.optional(),
controls: notificationControlSchema.array().optional(),
details: notificationDetailSchema.array().optional(),
text: z.string().optional(),
});
export type Notification = z.infer<typeof notificationSchema>;
// ============================================================================
// Status Bar Elements
//
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const iconSchema = z.object({
// MDI icon name (e.g. 'mdi:star').
icon: z.string().optional(),
// HA entity whose icon will be used when `icon` is not set.
entity: z.string().optional(),
// Whether to tint the icon color based on the entity's state.
stateColor: z.boolean().optional(),
});
export type Icon = z.infer<typeof iconSchema>;
// Extended internally to include a fallback icon that is not user-configurable.
export interface InternalIcon extends Icon {
fallback?: string;
}
+2
View File
@@ -26,6 +26,7 @@ export const statusBarConfigDefault = {
severity: statusBarItemDefault,
technology: statusBarItemDefault,
title: statusBarItemDefault,
upgrade: statusBarItemDefault,
},
position: 'bottom' as const,
style: 'popup' as const,
@@ -53,6 +54,7 @@ export const statusBarConfigSchema = z
),
severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity),
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
upgrade: statusBarItemBaseSchema.default(statusBarConfigDefault.items.upgrade),
})
.default(statusBarConfigDefault.items),
})
+1
View File
@@ -3110,6 +3110,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderStatusBarItem('resolution') /* */}
${this._renderStatusBarItem('technology') /* */}
${this._renderStatusBarItem('engine') /* */}
${this._renderStatusBarItem('upgrade') /* */}
</div>
`
: ''}
+9 -1
View File
@@ -6,6 +6,7 @@
"common": {
"advanced_camera_card": "Advanced Camera Card",
"advanced_camera_card_description": "An Advanced Camera Card",
"dismiss": "Dismiss",
"folder": "Folder",
"live": "Live",
"no_folder": "No folder to display",
@@ -557,7 +558,8 @@
"priority": "Item priority",
"resolution": "Resolution",
"technology": "Technology",
"title": "Title"
"title": "Title",
"upgrade": "Configuration upgrade notification"
},
"popup_seconds": "Status bar popup seconds",
"position": "Status bar position",
@@ -821,6 +823,12 @@
"media_viewer": {
"unseekable": "Seek time not found in media"
},
"notification": {
"upgrade": {
"heading": "Configuration upgrade available",
"text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor."
}
},
"thumbnail": {
"camera": "Camera",
"download": "Download media",
@@ -8,7 +8,7 @@
align-items: flex-end;
padding: 24px;
pointer-events: none;
z-index: $z-index-overlay-message;
z-index: $z-index-notification;
}
.backdrop {
@@ -17,7 +17,7 @@
pointer-events: auto;
}
.message {
.notification {
position: relative;
display: flex;
flex-direction: row;
@@ -30,23 +30,22 @@
padding-right: 48px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--advanced-camera-card-overlay-message-scrollbar-color)
transparent;
scrollbar-color: var(--advanced-camera-card-notification-scrollbar-color) transparent;
// Glassmorphism background
background: var(--advanced-camera-card-overlay-message-background);
backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter);
-webkit-backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter);
background: var(--advanced-camera-card-notification-background);
backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter);
-webkit-backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter);
// Borders & Edge highlights
border: var(--advanced-camera-card-overlay-message-border);
border-top: var(--advanced-camera-card-overlay-message-border-top);
border: var(--advanced-camera-card-notification-border);
border-top: var(--advanced-camera-card-notification-border-top);
border-radius: var(--advanced-camera-card-border-radius-final);
// Shadows
box-shadow: var(
--advanced-camera-card-box-shadow-override,
var(--advanced-camera-card-overlay-message-box-shadow)
var(--advanced-camera-card-notification-box-shadow)
);
pointer-events: auto;
@@ -56,7 +55,7 @@
}
// Exit animation
.message.exiting {
.notification.exiting {
animation: slideDown 0.25s ease-out forwards;
}
@@ -99,12 +98,12 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--advanced-camera-card-overlay-message-icon-color);
color: var(--advanced-camera-card-notification-icon-color);
filter: drop-shadow(
0 0 8px
color-mix(
in oklab,
var(--advanced-camera-card-overlay-message-icon-color),
var(--advanced-camera-card-notification-icon-color),
transparent 60%
)
);
@@ -119,7 +118,7 @@
font-size: 15px;
font-weight: 400;
line-height: 1.5;
color: var(--advanced-camera-card-overlay-message-text-color);
color: var(--advanced-camera-card-notification-text-color);
word-break: break-word;
}
@@ -135,7 +134,7 @@
background: color-mix(
in srgb,
var(--advanced-camera-card-overlay-message-close-color),
var(--advanced-camera-card-notification-close-color),
transparent 92%
);
}
@@ -150,7 +149,7 @@
color: color-mix(
in srgb,
var(--advanced-camera-card-overlay-message-close-color),
var(--advanced-camera-card-notification-close-color),
transparent 40%
);
@@ -166,17 +165,17 @@
}
// Base control hover color transition (from dimmed to full).
&:not([class^='emphasis-']):hover {
color: var(--advanced-camera-card-overlay-message-close-color);
&:not([class^='severity-']):hover {
color: var(--advanced-camera-card-notification-close-color);
}
&.emphasis-low {
&.severity-low {
color: var(--advanced-camera-card-foreground-severity-low-color);
}
&.emphasis-medium {
&.severity-medium {
color: var(--advanced-camera-card-foreground-severity-medium-color);
}
&.emphasis-high {
&.severity-high {
color: var(--advanced-camera-card-foreground-severity-high-color);
}
}
@@ -194,12 +193,12 @@
background: color-mix(
in srgb,
var(--advanced-camera-card-overlay-message-close-color),
var(--advanced-camera-card-notification-close-color),
transparent 95%
);
color: color-mix(
in srgb,
var(--advanced-camera-card-overlay-message-close-color),
var(--advanced-camera-card-notification-close-color),
transparent 40%
);
@@ -210,10 +209,10 @@
.close:hover {
background: color-mix(
in srgb,
var(--advanced-camera-card-overlay-message-close-color),
var(--advanced-camera-card-notification-close-color),
transparent 90%
);
color: var(--advanced-camera-card-overlay-message-close-color);
color: var(--advanced-camera-card-notification-close-color);
}
.close advanced-camera-card-icon {
@@ -233,7 +232,7 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--advanced-camera-card-overlay-message-text-color);
color: var(--advanced-camera-card-notification-text-color);
font-size: 14px;
line-height: 1.4;
@@ -261,15 +260,15 @@
}
}
&.emphasis-low advanced-camera-card-icon {
&.severity-low advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-low-color);
opacity: 1;
}
&.emphasis-medium advanced-camera-card-icon {
&.severity-medium advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-medium-color);
opacity: 1;
}
&.emphasis-high advanced-camera-card-icon {
&.severity-high advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-high-color);
opacity: 1;
}
@@ -282,10 +281,10 @@
border-top: 1px solid
color-mix(
in srgb,
var(--advanced-camera-card-overlay-message-text-color),
var(--advanced-camera-card-notification-text-color),
transparent 80%
);
color: var(--advanced-camera-card-overlay-message-text-color);
color: var(--advanced-camera-card-notification-text-color);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
+1
View File
@@ -64,6 +64,7 @@
.item.action {
cursor: pointer;
pointer-events: auto;
}
img.item,
+12 -12
View File
@@ -286,24 +286,24 @@
transparent 20%
);
/******************
* Overlay Message
******************/
--advanced-camera-card-overlay-message-background: linear-gradient(
/**************
* Notification
**************/
--advanced-camera-card-notification-background: linear-gradient(
135deg,
rgba(35, 35, 35, 0.75) 0%,
rgba(15, 15, 15, 0.65) 100%
);
--advanced-camera-card-overlay-message-backdrop-filter: blur(20px) saturate(180%);
--advanced-camera-card-overlay-message-border: 1px solid rgba(255, 255, 255, 0.1);
--advanced-camera-card-overlay-message-border-top: 1px solid rgba(255, 255, 255, 0.15);
--advanced-camera-card-overlay-message-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5),
--advanced-camera-card-notification-backdrop-filter: blur(20px) saturate(180%);
--advanced-camera-card-notification-border: 1px solid rgba(255, 255, 255, 0.1);
--advanced-camera-card-notification-border-top: 1px solid rgba(255, 255, 255, 0.15);
--advanced-camera-card-notification-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5),
0 2px 8px 0 rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--advanced-camera-card-overlay-message-text-color: #ffffff;
--advanced-camera-card-overlay-message-icon-color: var(
--advanced-camera-card-notification-text-color: #ffffff;
--advanced-camera-card-notification-icon-color: var(
--advanced-camera-card-foreground-primary
);
--advanced-camera-card-overlay-message-scrollbar-color: rgba(255, 255, 255, 0.2);
--advanced-camera-card-overlay-message-close-color: #ffffff;
--advanced-camera-card-notification-scrollbar-color: rgba(255, 255, 255, 0.2);
--advanced-camera-card-notification-close-color: #ffffff;
}
+10 -10
View File
@@ -522,20 +522,20 @@
// Menu border colors need to re-adjust based on redefined variable.
--wa-color-surface-border: var(--ha-color-border-neutral-quiet);
/******************
* Overlay Message
******************/
--advanced-camera-card-overlay-message-background: linear-gradient(
/**************
* Notification
**************/
--advanced-camera-card-notification-background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.85) 0%,
rgba(235, 235, 235, 0.75) 100%
);
--advanced-camera-card-overlay-message-text-color: var(--primary-text-color);
--advanced-camera-card-overlay-message-border: 1px solid rgba(0, 0, 0, 0.3);
--advanced-camera-card-overlay-message-border-top: 1px solid rgba(0, 0, 0, 0.2);
--advanced-camera-card-overlay-message-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3),
--advanced-camera-card-notification-text-color: var(--primary-text-color);
--advanced-camera-card-notification-border: 1px solid rgba(0, 0, 0, 0.3);
--advanced-camera-card-notification-border-top: 1px solid rgba(0, 0, 0, 0.2);
--advanced-camera-card-notification-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3),
0 2px 8px 0 rgba(0, 0, 0, 0.2), inset 0 0 0 1px rgba(255, 255, 255, 0.8);
--advanced-camera-card-overlay-message-scrollbar-color: rgba(0, 0, 0, 0.5);
--advanced-camera-card-overlay-message-close-color: #000000;
--advanced-camera-card-notification-scrollbar-color: rgba(0, 0, 0, 0.5);
--advanced-camera-card-notification-close-color: #000000;
--app-header-text-color: white;
}
+1 -1
View File
@@ -15,6 +15,6 @@ $z-index-loading: 6;
// Need menu to render above drawer (so the drawer button is below menu-submenus)
$z-index-menu: 4;
$z-index-overlay-message: 4;
$z-index-notification: 4;
$z-index-drawer: 3;
$z-index-status-bar: 2;
-34
View File
@@ -1,7 +1,6 @@
import { z } from 'zod';
import type { EffectOptions } from './card-controller/effects/types';
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
import { Severity } from './severity';
// UI-facing media types for galleries and views.
export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const;
@@ -64,24 +63,6 @@ export interface Message {
url?: MessageURL;
}
export interface MetadataField {
title: string;
icon?: Icon;
hint?: string;
emphasis?: Severity;
}
export interface OverlayMessageControl extends MetadataField {
callback: () => OverlayMessage | null | Promise<OverlayMessage | null>;
}
export interface OverlayMessage {
heading?: MetadataField;
controls?: OverlayMessageControl[];
details?: MetadataField[];
text?: string;
}
export type WebkitHTMLVideoElement = HTMLVideoElement & {
webkitDisplayingFullscreen: boolean;
webkitSupportsFullscreen: boolean;
@@ -182,21 +163,6 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [
'trigger',
] as const;
export interface Icon {
// If set, this icon will be used.
icon?: string;
// If icon is not set, this entity's icon will be used (and HA will be asked
// to render it).
entity?: string;
// Whether or not to change the icon color depending on entity state.
stateColor?: boolean;
// If an icon is not otherwise resolved / available, this will be used instead.
fallback?: string;
}
export interface Interaction {
action: string;
}
+18 -2
View File
@@ -28,10 +28,12 @@ import { SetReviewActionConfig } from '../config/schema/actions/custom/set-revie
import { SubstreamSelectActionConfig } from '../config/schema/actions/custom/substream-select.js';
import { ViewActionConfig } from '../config/schema/actions/custom/view.js';
import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js';
import type { Notification } from '../config/schema/actions/types.js';
import {
ActionConfig,
ActionsConfig,
Actions,
AdvancedCameraCardCustomActionConfig,
NotificationActionConfig,
} from '../config/schema/actions/types.js';
import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js';
import { PTZControlType } from '../config/schema/common/controls/ptz.js';
@@ -270,6 +272,20 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig
};
}
export function createNotificationAction(
notification: Notification,
options?: {
cardID?: string;
},
): NotificationActionConfig {
return {
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
notification,
...(options?.cardID && { card_id: options.cardID }),
};
}
/**
* Get an action configuration given a config and an interaction (e.g. 'tap').
* @param interaction The interaction: `tap`, `hold` or `double_tap`
@@ -278,7 +294,7 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig
*/
export function getActionConfigGivenAction(
interaction?: string,
config?: ActionsConfig | null,
config?: Actions | null,
): ActionConfig | ActionConfig[] | null {
if (!interaction || !config) {
return null;
+5
View File
@@ -0,0 +1,5 @@
import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js';
export function dispatchDismissNotificationEvent(element: HTMLElement): void {
fireAdvancedCameraCardEvent(element, 'notification:dismiss');
}
-13
View File
@@ -1,13 +0,0 @@
import { OverlayMessage } from '../types.js';
import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js';
export function dispatchShowOverlayMessageEvent(
element: HTMLElement,
message: OverlayMessage,
): void {
fireAdvancedCameraCardEvent(element, 'overlay-message:show', message);
}
export function dispatchDismissOverlayMessageEvent(element: HTMLElement): void {
fireAdvancedCameraCardEvent(element, 'overlay-message:dismiss');
}
@@ -28,7 +28,7 @@ describe('InfoAction', () => {
await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).toBeCalled();
expect(api.getNotificationManager().setNotification).toBeCalled();
});
it('should not handle info action without media', async () => {
@@ -45,6 +45,6 @@ describe('InfoAction', () => {
await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).not.toBeCalled();
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { NotificationAction } from '../../../../src/card-controller/actions/actions/notification';
import { createCardAPI } from '../../../test-utils';
describe('NotificationAction', () => {
it('should set notification on manager', async () => {
const api = createCardAPI();
const notification = {
heading: { text: 'Test Heading' },
text: 'Test text',
};
const action = new NotificationAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
notification,
},
);
await action.execute(api);
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith(
notification,
);
});
});
@@ -22,9 +22,10 @@ import { MoreInfoAction } from '../../../src/card-controller/actions/actions/mor
import { MuteAction } from '../../../src/card-controller/actions/actions/mute';
import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate';
import { NoneAction } from '../../../src/card-controller/actions/actions/none';
import { NotificationAction } from '../../../src/card-controller/actions/actions/notification';
import { PauseAction } from '../../../src/card-controller/actions/actions/pause';
import { PIPAction } from '../../../src/card-controller/actions/actions/pip';
import { PerformActionAction } from '../../../src/card-controller/actions/actions/perform-action';
import { PIPAction } from '../../../src/card-controller/actions/actions/pip';
import { PlayAction } from '../../../src/card-controller/actions/actions/play';
import { PTZAction } from '../../../src/card-controller/actions/actions/ptz';
import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls';
@@ -126,6 +127,13 @@ describe('ActionFactory', () => {
},
LogAction,
],
[
{
advanced_camera_card_action: 'notification' as const,
notification: { text: 'test' },
},
NotificationAction,
],
[
{
advanced_camera_card_action: 'media_player' as const,
@@ -121,6 +121,27 @@ describe('ConfigManager', () => {
expect(manager.getConfig()).toBeNull();
expect(manager.getNonOverriddenConfig()).toBeNull();
expect(manager.getRawConfig()).toBeNull();
expect(manager.isUpgradeable()).toBe(false);
});
describe('isUpgradeable', () => {
it('should return true for upgradeable config', () => {
const manager = new ConfigManager(createCardAPI());
manager.setConfig({
type: 'custom:frigate-card',
cameras: [TEST_CAMERAS.OFFICE],
});
expect(manager.isUpgradeable()).toBe(true);
});
it('should return false for non-upgradeable config', () => {
const manager = new ConfigManager(createCardAPI());
manager.setConfig({
type: 'custom:advanced-camera-card',
cameras: [TEST_CAMERAS.OFFICE],
});
expect(manager.isUpgradeable()).toBe(false);
});
});
it('should successfully parse basic config', () => {
+5 -5
View File
@@ -21,7 +21,7 @@ import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-man
import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
import { MessageManager } from '../../src/card-controller/message-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
import { NotificationManager } from '../../src/card-controller/notification-manager';
import { PIPManager } from '../../src/card-controller/pip-manager';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
@@ -54,7 +54,7 @@ vi.mock('../../src/card-controller/media-info-manager');
vi.mock('../../src/card-controller/media-player-manager');
vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/overlay-message-manager');
vi.mock('../../src/card-controller/notification-manager');
vi.mock('../../src/card-controller/pip-manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
@@ -226,9 +226,9 @@ describe('CardController', () => {
);
});
it('getOverlayMessageManager', () => {
expect(createController().getOverlayMessageManager()).toBe(
vi.mocked(OverlayMessageManager).mock.instances[0],
it('getNotificationManager', () => {
expect(createController().getNotificationManager()).toBe(
vi.mocked(NotificationManager).mock.instances[0],
);
});
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
import { NotificationManager } from '../../src/card-controller/notification-manager';
import { CardNotificationAPI } from '../../src/card-controller/types';
describe('NotificationManager', () => {
const cardElementManager = mock<CardElementManager>();
const api = mock<CardNotificationAPI>();
beforeEach(() => {
vi.clearAllMocks();
api.getCardElementManager.mockReturnValue(cardElementManager);
});
it('should be constructed', () => {
const manager = new NotificationManager(api);
expect(manager).toBeDefined();
expect(manager.getNotification()).toBeNull();
expect(manager.hasNotification()).toBeFalsy();
});
it('should set and get notification', () => {
const manager = new NotificationManager(api);
const notification = { text: 'foo' };
manager.setNotification(notification);
expect(manager.getNotification()).toBe(notification);
expect(manager.hasNotification()).toBeTruthy();
expect(cardElementManager.update).toHaveBeenCalled();
});
it('should reset notification', () => {
const manager = new NotificationManager(api);
manager.setNotification({ text: 'foo' });
vi.clearAllMocks();
manager.reset();
expect(manager.getNotification()).toBeNull();
expect(manager.hasNotification()).toBeFalsy();
expect(cardElementManager.update).toHaveBeenCalled();
});
it('should not update if reset is called with no notification', () => {
const manager = new NotificationManager(api);
manager.reset();
expect(cardElementManager.update).not.toHaveBeenCalled();
});
});
@@ -1,51 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardElementManager } from '../../src/card-controller/card-element-manager';
import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager';
import { CardOverlayMessageAPI } from '../../src/card-controller/types';
describe('OverlayMessageManager', () => {
const cardElementManager = mock<CardElementManager>();
const api = mock<CardOverlayMessageAPI>();
beforeEach(() => {
vi.clearAllMocks();
api.getCardElementManager.mockReturnValue(cardElementManager);
});
it('should be constructed', () => {
const manager = new OverlayMessageManager(api);
expect(manager).toBeDefined();
expect(manager.getMessage()).toBeNull();
expect(manager.hasMessage()).toBeFalsy();
});
it('should set and get message', () => {
const manager = new OverlayMessageManager(api);
const message = { text: 'foo' };
manager.setMessage(message);
expect(manager.getMessage()).toBe(message);
expect(manager.hasMessage()).toBeTruthy();
expect(cardElementManager.update).toHaveBeenCalled();
});
it('should reset message', () => {
const manager = new OverlayMessageManager(api);
manager.setMessage({ text: 'foo' });
vi.clearAllMocks();
manager.reset();
expect(manager.getMessage()).toBeNull();
expect(manager.hasMessage()).toBeFalsy();
expect(cardElementManager.update).toHaveBeenCalled();
});
it('should not update if reset is called with no message', () => {
const manager = new OverlayMessageManager(api);
manager.reset();
expect(cardElementManager.update).not.toHaveBeenCalled();
});
});
@@ -241,6 +241,56 @@ describe('StatusBarItemManager', () => {
});
});
describe('upgrade', () => {
it('should show upgrade item when upgradeable', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems({
isUpgradeable: true,
});
expect(items).toContainEqual(
expect.objectContaining({
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: 'mdi:update',
severity: 'medium',
actions: expect.objectContaining({
tap_action: expect.objectContaining({
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
}),
}),
}),
);
});
it('should not show upgrade item when not upgradeable', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems({
isUpgradeable: false,
});
expect(items).not.toContainEqual(
expect.objectContaining({
icon: 'mdi:update',
}),
);
});
it('should not show upgrade item by default', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems();
expect(items).not.toContainEqual(
expect.objectContaining({
icon: 'mdi:update',
}),
);
});
});
describe('severity', () => {
it('should have severity in a viewer view', () => {
const manager = new StatusBarItemManager(createCardAPI());
@@ -1,18 +1,39 @@
import { format } from 'date-fns';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager';
import { ActionFactory } from '../../../src/card-controller/actions/factory';
import { CardController } from '../../../src/card-controller/controller';
import { ViewItemManager } from '../../../src/card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import {
MediaDetailsController,
OverlayControlsContext,
NotificationControlsContext,
} from '../../../src/components-lib/media/details-controller';
import { OverlayMessageControl } from '../../../src/types';
import { NotificationControl } from '../../../src/config/schema/actions/types';
import { formatDateAndTime } from '../../../src/utils/basic';
import { downloadMedia, navigateToTimeline } from '../../../src/utils/media-actions';
import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import { createCardAPI, createFolder, TestViewMedia } from '../../test-utils';
vi.mock('../../../src/utils/media-actions', async (importOriginal) => ({
...((await importOriginal()) as object),
downloadMedia: vi.fn(),
navigateToTimeline: vi.fn(),
}));
async function executeControlAction(
control: NotificationControl,
api: CardController,
): Promise<void> {
const tapAction = control.actions?.tap_action;
assert(tapAction && !Array.isArray(tapAction));
const action = new ActionFactory().createAction({}, tapAction);
await action?.execute(api);
}
describe('MediaDetailsController', () => {
describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => {
@@ -24,7 +45,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Person, Car: Tag1, Tag2 50.00%');
expect(controller.getHeading()?.text).toBe('Person, Car: Tag1, Tag2 50.00%');
});
it('should set heading on event with tags', () => {
@@ -34,7 +55,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Tag1, Tag2');
expect(controller.getHeading()?.text).toBe('Tag1, Tag2');
});
it('should set heading on event with what', () => {
@@ -44,7 +65,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getHeading()?.title).toBe('Person, Car');
expect(controller.getHeading()?.text).toBe('Person, Car');
});
it('should set null heading on event with no other information', () => {
@@ -73,7 +94,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getHeading()?.title).toBe('Camera Title');
expect(controller.getHeading()?.text).toBe('Camera Title');
});
it('should set heading on recording without camera metadata', () => {
@@ -106,9 +127,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Test Event',
icon: { icon: 'mdi:rename' },
hint: 'Title',
text: 'Test Event',
icon: 'mdi:rename',
tooltip: 'Title',
});
});
@@ -121,7 +142,7 @@ describe('MediaDetailsController', () => {
controller.calculate(null, item);
expect(controller.getDetails()).toEqual([
{
title: 'Test Event',
text: 'Test Event',
},
]);
});
@@ -136,7 +157,7 @@ describe('MediaDetailsController', () => {
controller.calculate(null, item);
expect(controller.getDetails()).not.toContainEqual(
expect.objectContaining({
title: 'Test Event',
text: 'Test Event',
}),
);
});
@@ -153,9 +174,9 @@ describe('MediaDetailsController', () => {
// Use formatDateAndTime to generate expected value (formats in local time with seconds)
expect(controller.getDetails()).toContainEqual({
title: formatDateAndTime(startTime, true),
hint: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' },
text: formatDateAndTime(startTime, true),
tooltip: 'Start',
icon: 'mdi:calendar-clock-outline',
});
});
@@ -169,9 +190,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
text: '1m 0s',
tooltip: 'Duration',
icon: 'mdi:clock-outline',
});
});
@@ -185,9 +206,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
text: 'In Progress',
tooltip: 'Duration',
icon: 'mdi:clock-outline',
});
});
@@ -201,9 +222,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress',
hint: 'Duration',
icon: { icon: 'mdi:clock-outline' },
text: '1m 0s In Progress',
tooltip: 'Duration',
icon: 'mdi:clock-outline',
});
});
});
@@ -222,9 +243,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({
title: 'Camera Title',
hint: 'Camera',
icon: { icon: 'mdi:cctv' },
text: 'Camera Title',
tooltip: 'Camera',
icon: 'mdi:cctv',
});
});
@@ -237,9 +258,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2',
hint: 'Where',
icon: { icon: 'mdi:map-marker-outline' },
text: 'Where1, Where2',
tooltip: 'Where',
icon: 'mdi:map-marker-outline',
});
});
@@ -252,9 +273,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2',
hint: 'Tag',
icon: { icon: 'mdi:tag' },
text: 'Tag1, Tag2',
tooltip: 'Tag',
icon: 'mdi:tag',
});
});
@@ -267,9 +288,9 @@ describe('MediaDetailsController', () => {
// Use format() to generate expected value (formats in local time)
expect(controller.getDetails()).toContainEqual({
title: format(seekTime, 'HH:mm:ss'),
hint: 'Seek',
icon: { icon: 'mdi:clock-fast' },
text: format(seekTime, 'HH:mm:ss'),
tooltip: 'Seek',
icon: 'mdi:clock-fast',
});
});
it('should set heading on review', () => {
@@ -282,10 +303,10 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title');
expect(heading?.emphasis).toBe('high');
expect(heading?.icon).toEqual({ icon: 'mdi:circle-medium' });
expect(heading?.hint).toBe('Severity: High');
expect(heading?.text).toBe('Review Title');
expect(heading?.severity).toBe('high');
expect(heading?.icon).toBe('mdi:circle-medium');
expect(heading?.tooltip).toBe('Severity: High');
});
it('should set heading on review without severity', () => {
@@ -298,8 +319,8 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title');
expect(heading?.emphasis).toBeUndefined();
expect(heading?.text).toBe('Review Title');
expect(heading?.severity).toBeUndefined();
});
it('should set null heading on review with no title', () => {
@@ -321,12 +342,12 @@ describe('MediaDetailsController', () => {
});
});
describe('should get message', () => {
describe('should get notification', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should get message', () => {
it('should get notification', () => {
const item = new TestViewMedia({
title: 'Test Title',
what: ['person'],
@@ -336,25 +357,25 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.heading?.title).toBe('Person');
expect(message.details).toContainEqual({
title: 'Test Title',
const notification = controller.getNotification();
expect(notification.heading?.text).toBe('Person');
expect(notification.details).toContainEqual({
text: 'Test Title',
});
expect(message.text).toBe('Test Description');
expect(notification.text).toBe('Test Description');
});
it('should get message without media', () => {
it('should get notification without media', () => {
const item = new ViewFolder(createFolder(), []);
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.text).toBeUndefined();
const notification = controller.getNotification();
expect(notification.text).toBeUndefined();
});
it('should get message with null description', () => {
it('should get notification with null description', () => {
const item = new TestViewMedia({
description: null,
});
@@ -362,11 +383,11 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage();
expect(message.text).toBeUndefined();
const notification = controller.getNotification();
expect(notification.text).toBeUndefined();
});
it('should get message with controls', async () => {
it('should get notification with controls', async () => {
const item = new TestViewMedia({
title: 'Test Title',
mediaType: ViewMediaType.Review,
@@ -390,45 +411,52 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
const controls = message.controls;
const notification = controller.getNotification(context);
const controls = notification.controls;
assert(controls);
expect(controls).toHaveLength(4);
vi.spyOn(console, 'warn').mockImplementation(() => {});
// 1. Review control
expect(controls?.[0].title).toBe('Mark as reviewed');
const reviewResult = await controls?.[0].callback?.();
expect(reviewResult).not.toBeNull();
expect(controls?.[0].tooltip).toBe('Mark as reviewed');
expect(controls?.[0].dismiss).toBe(false);
await executeControlAction(controls[0], cardAPI);
expect(cardAPI.getNotificationManager().setNotification).toHaveBeenCalled();
// 1b. Review control (failure)
vi.mocked(cardAPI.getNotificationManager().setNotification).mockClear();
viewItemManager.reviewMedia.mockRejectedValue(new Error('fail'));
const reviewFailureResult = await controls?.[0].callback?.();
expect(reviewFailureResult).toBeNull();
await executeControlAction(controls[0], cardAPI);
expect(cardAPI.getNotificationManager().setNotification).not.toHaveBeenCalled();
// 2. Favorite control
expect(controls?.[1].title).toBe('Media will be indefinitely retained');
const favoriteResult = await controls?.[1].callback?.();
expect(favoriteResult).not.toBeNull();
expect(controls?.[1].tooltip).toBe('Media will be indefinitely retained');
expect(controls?.[1].dismiss).toBe(false);
await executeControlAction(controls[1], cardAPI);
expect(cardAPI.getNotificationManager().setNotification).toHaveBeenCalled();
// 2b. Favorite control (failure)
vi.mocked(cardAPI.getNotificationManager().setNotification).mockClear();
viewItemManager.favorite.mockRejectedValue(new Error('fail'));
const favoriteFailureResult = await controls?.[1].callback?.();
expect(favoriteFailureResult).toBeNull();
await executeControlAction(controls[1], cardAPI);
expect(cardAPI.getNotificationManager().setNotification).not.toHaveBeenCalled();
// 3. Download control
expect(controls?.[2].title).toBe('Download media');
const downloadResult = await controls?.[2].callback?.();
expect(downloadResult).toBeNull();
expect(controls?.[2].tooltip).toBe('Download media');
expect(controls?.[2].dismiss).toBe(true);
vi.mocked(downloadMedia).mockResolvedValue(true);
await executeControlAction(controls[2], cardAPI);
expect(downloadMedia).toHaveBeenCalledWith(item, viewItemManager);
// 4. Timeline control
expect(controls?.[3].title).toBe('See media in timeline');
const timelineResult = await controls?.[3].callback?.();
expect(timelineResult).toBeNull();
expect(controls?.[3].tooltip).toBe('See media in timeline');
expect(controls?.[3].dismiss).toBe(true);
await executeControlAction(controls[3], cardAPI);
expect(navigateToTimeline).toHaveBeenCalledWith(item, viewManagerEpoch);
});
it('should get message with controls for already reviewed/favorited items', () => {
it('should get notification with controls for already reviewed/favorited items', () => {
const item = new TestViewMedia({
mediaType: ViewMediaType.Review,
reviewed: true,
@@ -444,18 +472,18 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
const controls = message.controls;
const notification = controller.getNotification(context);
const controls = notification.controls;
expect(controls).toHaveLength(2);
expect(controls?.[0].title).toBe('Mark as unreviewed');
expect(controls?.[0].icon).toEqual({ icon: 'mdi:check-circle' });
expect(controls?.[0].tooltip).toBe('Mark as unreviewed');
expect(controls?.[0].icon).toBe('mdi:check-circle');
expect(controls?.[1].emphasis).toBe('medium');
expect(controls?.[1].icon).toEqual({ icon: 'mdi:star' });
expect(controls?.[1].severity).toBe('medium');
expect(controls?.[1].icon).toBe('mdi:star');
});
it('should get message with controls when item has no ID', () => {
it('should get notification with controls when item has no ID', () => {
const item = new TestViewMedia({
id: null,
});
@@ -469,11 +497,11 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
expect(message.controls).toHaveLength(0);
const notification = controller.getNotification(context);
expect(notification.controls).toHaveLength(0);
});
it('should get message with controls when context has no capabilities', () => {
it('should get notification with controls when context has no capabilities', () => {
const item = new TestViewMedia({
id: 'id',
});
@@ -482,8 +510,8 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController();
controller.calculate(null, item);
const message = controller.getMessage(context);
expect(message.controls).toHaveLength(0);
const notification = controller.getNotification(context);
expect(notification.controls).toHaveLength(0);
});
it('should get empty controls when item is null', () => {
@@ -492,7 +520,7 @@ describe('MediaDetailsController', () => {
// Use cast to unknown first to avoid any-related lint errors.
const controls = (
controller as unknown as {
_getControls: (context: OverlayControlsContext) => OverlayMessageControl[];
_getControls: (context: NotificationControlsContext) => NotificationControl[];
}
)._getControls({});
expect(controls).toEqual([]);
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { iconSchema } from '../../../../src/config/schema/common/icon';
describe('iconSchema', () => {
it('should parse icon with all fields', () => {
expect(
iconSchema.parse({
icon: 'mdi:star',
entity: 'light.office',
stateColor: true,
}),
).toEqual({
icon: 'mdi:star',
entity: 'light.office',
stateColor: true,
});
});
it('should parse empty object', () => {
expect(iconSchema.parse({})).toEqual({});
});
});
+28
View File
@@ -441,6 +441,10 @@ describe('config defaults', () => {
enabled: true,
priority: 50,
},
upgrade: {
enabled: true,
priority: 50,
},
},
popup_seconds: 3,
position: 'bottom',
@@ -1066,6 +1070,30 @@ describe('config defaults', () => {
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'mute',
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'notification',
notification: {
heading: {
text: 'Attention',
icon: 'mdi:alert',
severity: 'high',
},
text: 'Something happened.',
details: [{ text: 'Detail 1', icon: 'mdi:info' }],
controls: [
{
icon: 'mdi:check',
tooltip: 'Acknowledge',
dismiss: true,
},
{
icon: 'mdi:eye',
dismiss: false,
},
],
},
},
{
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'pause',
+2 -2
View File
@@ -45,7 +45,7 @@ import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manage
import { MediaPlayerManager } from '../src/card-controller/media-player-manager';
import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager';
import { NotificationManager } from '../src/card-controller/notification-manager';
import { PIPManager } from '../src/card-controller/pip-manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
@@ -678,7 +678,7 @@ export const createCardAPI = (): CardController => {
api.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
api.getMessageManager.mockReturnValue(mock<MessageManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getOverlayMessageManager.mockReturnValue(mock<OverlayMessageManager>());
api.getNotificationManager.mockReturnValue(mock<NotificationManager>());
api.getPIPManager.mockReturnValue(mock<PIPManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
+22
View File
@@ -10,6 +10,7 @@ import {
createInternalCallbackAction,
createLogAction,
createMediaPlayerAction,
createNotificationAction,
createPerformAction,
createPTZAction,
createPTZControlsAction,
@@ -357,6 +358,27 @@ describe('createSetReviewAction', () => {
});
});
describe('createNotificationAction', () => {
it('should create notification action', () => {
const notification = { text: 'test' };
expect(createNotificationAction(notification)).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
notification,
});
});
it('should create notification action with cardID', () => {
const notification = { text: 'test' };
expect(createNotificationAction(notification, { cardID: 'card_id' })).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
notification,
card_id: 'card_id',
});
});
});
describe('getActionConfigGivenAction', () => {
const action = createViewAction('clips');
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { dispatchDismissNotificationEvent } from '../../src/utils/notification';
// @vitest-environment jsdom
describe('notification utils', () => {
it('should dispatch dismiss notification event', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:notification:dismiss', handler);
dispatchDismissNotificationEvent(element);
expect(handler).toHaveBeenCalled();
});
});
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import {
dispatchDismissOverlayMessageEvent,
dispatchShowOverlayMessageEvent,
} from '../../src/utils/overlay-message';
// @vitest-environment jsdom
describe('overlay-message utils', () => {
it('should dispatch show overlay message event', () => {
const element = document.createElement('div');
const message = { text: 'test' };
const handler = vi.fn();
element.addEventListener('advanced-camera-card:overlay-message:show', handler);
dispatchShowOverlayMessageEvent(element, message);
expect(handler).toHaveBeenCalled();
const event = handler.mock.calls[0][0];
expect(event.detail).toBe(message);
});
it('should dispatch dismiss overlay message event', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('advanced-camera-card:overlay-message:dismiss', handler);
dispatchDismissOverlayMessageEvent(element);
expect(handler).toHaveBeenCalled();
});
});