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. - **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. - **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 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`
Pause the selected media. Pause the selected media.
@@ -740,6 +786,29 @@ elements:
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: info 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 - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-n-circle icon: mdi:alpha-n-circle
title: Turn off substream 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. | | `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). | | `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`. | | `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)). | | `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` ## `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. | | `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`. | | `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`. | | `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)). | | `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` ## `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. | | `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!` | | `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`. | | `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)). | | `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 ## Fully expanded reference
@@ -331,6 +334,7 @@ elements:
expand: false expand: false
string: 'Intruder alert!' string: 'Intruder alert!'
priority: 50 priority: 50
severity: high
sufficient: false sufficient: false
- type: custom:advanced-camera-card-status-bar-icon - type: custom:advanced-camera-card-status-bar-icon
enabled: true enabled: true
@@ -338,6 +342,7 @@ elements:
expand: false expand: false
icon: 'mdi:cow' icon: 'mdi:cow'
priority: 50 priority: 50
severity: medium
sufficient: false sufficient: false
- type: custom:advanced-camera-card-status-bar-image - type: custom:advanced-camera-card-status-bar-image
enabled: true enabled: true
@@ -345,5 +350,6 @@ elements:
expand: false expand: false
image: https://my.site.com/status.png image: https://my.site.com/status.png
priority: 50 priority: 50
severity: low
sufficient: false 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). | | `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). |
| `technology` | The detected media technology (if any). | | `technology` | The detected media technology (if any). |
| `title` | The media title. | | `title` | The media title. |
| `upgrade` | An indicator that appears when a configuration upgrade is available. |
### Options for each item ### Options for each item
@@ -85,4 +86,7 @@ status_bar:
title: title:
enabled: true enabled: true
priority: 50 priority: 50
upgrade:
enabled: true
priority: 50
``` ```
+36
View File
@@ -729,6 +729,42 @@ cameras:
id: office-webrtc 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 ## Overriding configuration
You can override card configuration when certain [conditions](configuration/conditions.md) are met. 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 { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras'; import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const'; import { AdvancedCameraCardView } from '../config/schema/common/const';
import { InternalIcon } from '../config/schema/common/icon';
import { BaseQuery, QueryFilters, QuerySource } from '../query-source'; import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
import { CapabilityKey, Endpoint, Icon } from '../types'; import { CapabilityKey, Endpoint } from '../types';
import { ViewMedia } from '../view/item'; import { ViewMedia } from '../view/item';
// ==== // ====
@@ -118,7 +119,7 @@ export interface CapabilitySearchOptions {
export interface CameraManagerCameraMetadata { export interface CameraManagerCameraMetadata {
title: string; title: string;
icon: Icon; icon: InternalIcon;
// Engine icon is just a string since it will never be entity-derived. // Engine icon is just a string since it will never be entity-derived.
engineIcon?: string; engineIcon?: string;
+2 -2
View File
@@ -16,8 +16,8 @@ export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(api.getCameraManager(), item); controller.calculate(api.getCameraManager(), item);
api.getOverlayMessageManager().setMessage( api.getNotificationManager().setNotification(
controller.getMessage({ controller.getNotification({
hass: api.getHASSManager().getHASS() ?? undefined, hass: api.getHASSManager().getHASS() ?? undefined,
viewItemManager: api.getViewItemManager(), viewItemManager: api.getViewItemManager(),
viewManagerEpoch: api.getViewManager().getEpoch(), 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 { MuteAction } from './actions/mute';
import { NavigateAction } from './actions/navigate'; import { NavigateAction } from './actions/navigate';
import { NoneAction } from './actions/none'; import { NoneAction } from './actions/none';
import { NotificationAction } from './actions/notification';
import { PauseAction } from './actions/pause'; import { PauseAction } from './actions/pause';
import { PerformActionAction } from './actions/perform-action'; import { PerformActionAction } from './actions/perform-action';
import { PIPAction } from './actions/pip'; import { PIPAction } from './actions/pip';
@@ -163,6 +164,8 @@ export class ActionFactory {
return new PTZControlsAction(context, action, options?.config); return new PTZControlsAction(context, action, options?.config);
case 'log': case 'log':
return new LogAction(context, action, options?.config); return new LogAction(context, action, options?.config);
case 'notification':
return new NotificationAction(context, action, options?.config);
case 'status_bar': case 'status_bar':
return new StatusBarAction(context, action, options?.config); return new StatusBarAction(context, action, options?.config);
case 'reload': case 'reload':
+8 -2
View File
@@ -28,6 +28,7 @@ export class ConfigManager {
private _overriddenConfig: AdvancedCameraCardConfig | null = null; private _overriddenConfig: AdvancedCameraCardConfig | null = null;
private _rawConfig: RawAdvancedCameraCardConfig | null = null; private _rawConfig: RawAdvancedCameraCardConfig | null = null;
private _cardWideConfig: CardWideConfig | null = null; private _cardWideConfig: CardWideConfig | null = null;
private _upgradeable = false;
private _overridesManager = new OverridesManager(() => this._processOverrideConfig()); private _overridesManager = new OverridesManager(() => this._processOverrideConfig());
constructor(api: CardConfigAPI) { constructor(api: CardConfigAPI) {
@@ -54,17 +55,22 @@ export class ConfigManager {
return this._rawConfig; return this._rawConfig;
} }
public isUpgradeable(): boolean {
return this._upgradeable;
}
public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void { public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void {
if (!inputConfig) { if (!inputConfig) {
throw new Error(localize('error.invalid_configuration')); throw new Error(localize('error.invalid_configuration'));
} }
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig); const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
this._upgradeable = isConfigUpgradeable(inputConfig);
if (!parseResult.success) { if (!parseResult.success) {
const configUpgradeable = isConfigUpgradeable(inputConfig);
const hint = getParseError(parseResult.error); const hint = getParseError(parseResult.error);
let upgradeMessage = ''; let upgradeMessage = '';
if (configUpgradeable) { if (this._upgradeable) {
upgradeMessage = `${localize('error.upgrade_available')}. `; upgradeMessage = `${localize('error.upgrade_available')}. `;
} }
throw new Error( throw new Error(
+6 -6
View File
@@ -31,7 +31,7 @@ import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager'; import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager'; import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager'; import { MicrophoneManager } from './microphone-manager';
import { OverlayMessageManager } from './overlay-message-manager'; import { NotificationManager } from './notification-manager';
import { PIPManager } from './pip-manager'; import { PIPManager } from './pip-manager';
import { QueryStringManager } from './query-string-manager'; import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager'; import { StatusBarItemManager } from './status-bar-item-manager';
@@ -58,7 +58,7 @@ import {
CardMediaPlayerAPI, CardMediaPlayerAPI,
CardMessageAPI, CardMessageAPI,
CardMicrophoneAPI, CardMicrophoneAPI,
CardOverlayMessageAPI, CardNotificationAPI,
CardPIPAPI, CardPIPAPI,
CardQueryStringAPI, CardQueryStringAPI,
CardStyleAPI, CardStyleAPI,
@@ -90,7 +90,7 @@ export class CardController
CardMediaPlayerAPI, CardMediaPlayerAPI,
CardMessageAPI, CardMessageAPI,
CardMicrophoneAPI, CardMicrophoneAPI,
CardOverlayMessageAPI, CardNotificationAPI,
CardQueryStringAPI, CardQueryStringAPI,
CardStyleAPI, CardStyleAPI,
CardTriggersAPI, CardTriggersAPI,
@@ -125,7 +125,7 @@ export class CardController
private _mediaPlayerManager = new MediaPlayerManager(this); private _mediaPlayerManager = new MediaPlayerManager(this);
private _messageManager = new MessageManager(this); private _messageManager = new MessageManager(this);
private _microphoneManager = new MicrophoneManager(this); private _microphoneManager = new MicrophoneManager(this);
private _overlayMessageManager = new OverlayMessageManager(this); private _notificationManager = new NotificationManager(this);
private _pipManager = new PIPManager(this); private _pipManager = new PIPManager(this);
private _queryStringManager = new QueryStringManager(this); private _queryStringManager = new QueryStringManager(this);
private _statusBarItemManager = new StatusBarItemManager(this); private _statusBarItemManager = new StatusBarItemManager(this);
@@ -252,8 +252,8 @@ export class CardController
this._microphoneManager = new MicrophoneManager(this); this._microphoneManager = new MicrophoneManager(this);
} }
public getOverlayMessageManager(): OverlayMessageManager { public getNotificationManager(): NotificationManager {
return this._overlayMessageManager; return this._notificationManager;
} }
public getPIPManager(): PIPManager { 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 { CameraManager } from '../camera-manager/manager';
import { StatusBarItem } from '../config/schema/actions/types'; import { StatusBarItem } from '../config/schema/actions/types';
import { StatusBarConfig } from '../config/schema/status-bar'; import { StatusBarConfig } from '../config/schema/status-bar';
import { localize } from '../localize/localize';
import { MediaLoadedInfo } from '../types'; import { MediaLoadedInfo } from '../types';
import { createNotificationAction } from '../utils/action';
import { View } from '../view/view'; import { View } from '../view/view';
import { CardStatusBarAPI } from './types'; import { CardStatusBarAPI } from './types';
@@ -42,6 +44,7 @@ export class StatusBarItemManager {
cameraManager?: CameraManager | null; cameraManager?: CameraManager | null;
view?: View | null; view?: View | null;
mediaLoadedInfo?: MediaLoadedInfo | null; mediaLoadedInfo?: MediaLoadedInfo | null;
isUpgradeable?: boolean;
}): StatusBarItem[] { }): StatusBarItem[] {
const cameraMetadata = options?.view?.camera const cameraMetadata = options?.view?.camera
? options?.cameraManager?.getCameraMetadata(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, ...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 { MediaPlayerManager } from './media-player-manager';
import type { MessageManager } from './message-manager'; import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-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 { PIPManager } from './pip-manager';
import type { QueryStringManager } from './query-string-manager'; import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager'; import type { StatusBarItemManager } from './status-bar-item-manager';
@@ -54,7 +54,7 @@ export interface CardActionsAPI {
getMediaPlayerManager(): MediaPlayerManager; getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager; getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager; getMicrophoneManager(): MicrophoneManager;
getOverlayMessageManager(): OverlayMessageManager; getNotificationManager(): NotificationManager;
getPIPManager(): PIPManager; getPIPManager(): PIPManager;
getStatusBarItemManager(): StatusBarItemManager; getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager; getTriggersManager(): TriggersManager;
@@ -257,7 +257,7 @@ export interface CardMessageAPI {
getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager;
} }
export interface CardOverlayMessageAPI { export interface CardNotificationAPI {
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
} }
+11 -11
View File
@@ -16,7 +16,7 @@ import './components/menu.js';
import { AdvancedCameraCardMenu } from './components/menu.js'; import { AdvancedCameraCardMenu } from './components/menu.js';
import './components/message.js'; import './components/message.js';
import { renderMessage } from './components/message.js'; import { renderMessage } from './components/message.js';
import './components/overlay-message.js'; import './components/notification.js';
import './components/overlay.js'; import './components/overlay.js';
import { AdvancedCameraCardOverlay } from './components/overlay.js'; import { AdvancedCameraCardOverlay } from './components/overlay.js';
import './components/status-bar'; import './components/status-bar';
@@ -32,7 +32,7 @@ import { REPO_URL } from './const.js';
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import cardStyle from './scss/card.scss'; 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 { hasAction } from './utils/action.js';
import { getReleaseVersion } from './utils/diagnostics'; import { getReleaseVersion } from './utils/diagnostics';
@@ -288,6 +288,7 @@ class AdvancedCameraCard extends LitElement {
cameraManager: this._controller.getCameraManager(), cameraManager: this._controller.getCameraManager(),
view: this._controller.getViewManager().getView(), view: this._controller.getViewManager().getView(),
mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(), mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
isUpgradeable: this._controller.getConfigManager().isUpgradeable(),
})} })}
.config=${this._config.status_bar} .config=${this._config.status_bar}
></advanced-camera-card-status-bar> ></advanced-camera-card-status-bar>
@@ -374,11 +375,8 @@ class AdvancedCameraCard extends LitElement {
() => this.requestUpdate() /* Refresh play/pause menu button */ () => this.requestUpdate() /* Refresh play/pause menu button */
} }
@advanced-camera-card:focus=${() => this.focus()} @advanced-camera-card:focus=${() => this.focus()}
@advanced-camera-card:overlay-message:show=${( @advanced-camera-card:notification:dismiss=${() =>
ev: CustomEvent<OverlayMessage>, this._controller.getNotificationManager().reset()}
) => this._controller.getOverlayMessageManager().setMessage(ev.detail)}
@advanced-camera-card:overlay-message:dismiss=${() =>
this._controller.getOverlayMessageManager().reset()}
> >
${showLoading ${showLoading
? html`<advanced-camera-card-loading ? html`<advanced-camera-card-loading
@@ -459,10 +457,12 @@ class AdvancedCameraCard extends LitElement {
> >
</advanced-camera-card-elements>` </advanced-camera-card-elements>`
: ``} : ``}
${this._controller.getOverlayMessageManager().getMessage() ${this._controller.getNotificationManager().getNotification()
? html`<advanced-camera-card-overlay-message ? html`<advanced-camera-card-notification
.message=${this._controller.getOverlayMessageManager().getMessage()} .notification=${this._controller
></advanced-camera-card-overlay-message>` .getNotificationManager()
.getNotification()}
></advanced-camera-card-notification>`
: ''} : ''}
</ha-card>`, </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 motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg'; import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg';
import { Icon } from '../config/schema/common/icon';
import { HomeAssistant } from '../ha/types'; import { HomeAssistant } from '../ha/types';
import irisSVG from '../images/iris.svg'; import irisSVG from '../images/iris.svg';
import { Icon } from '../types';
export class IconController { export class IconController {
public getCustomIcon(icon?: Icon): string | null { 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 { CameraManagerCameraMetadata } from '../../camera-manager/types';
import { ViewItemManager } from '../../card-controller/view/item-manager'; import { ViewItemManager } from '../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../card-controller/view/types'; import { ViewManagerEpoch } from '../../card-controller/view/types';
import {
Notification,
NotificationControl,
NotificationDetail,
} from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types'; import { createInternalCallbackAction } from '../../utils/action';
import { getDurationString, prettifyTitle } from '../../utils/basic'; import { getDurationString, prettifyTitle } from '../../utils/basic';
import { import {
downloadMedia, downloadMedia,
@@ -17,7 +22,7 @@ import { ViewItem } from '../../view/item';
import { ViewItemClassifier } from '../../view/item-classifier'; import { ViewItemClassifier } from '../../view/item-classifier';
import { ViewItemCapabilities } from '../../view/types'; import { ViewItemCapabilities } from '../../view/types';
export interface OverlayControlsContext { export interface NotificationControlsContext {
hass?: HomeAssistant; hass?: HomeAssistant;
viewItemManager?: ViewItemManager; viewItemManager?: ViewItemManager;
viewManagerEpoch?: ViewManagerEpoch; viewManagerEpoch?: ViewManagerEpoch;
@@ -29,8 +34,8 @@ export interface OverlayControlsContext {
} }
export class MediaDetailsController { export class MediaDetailsController {
private _details: MetadataField[] = []; private _details: NotificationDetail[] = [];
private _heading: MetadataField | null = null; private _heading: NotificationDetail | null = null;
private _item: ViewItem | null = null; private _item: ViewItem | null = null;
public calculate( public calculate(
@@ -61,7 +66,7 @@ export class MediaDetailsController {
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
this._heading = whatWithTags this._heading = whatWithTags
? { title: `${whatWithTags}${score ? ` ${score}` : ''}` } ? { text: `${whatWithTags}${score ? ` ${score}` : ''}` }
: null; : null;
return; return;
} }
@@ -72,13 +77,13 @@ export class MediaDetailsController {
this._heading = title this._heading = title
? { ? {
title: title, text: title,
emphasis: severity ?? undefined, severity: severity ?? undefined,
hint: tooltip:
localize('common.severity') + localize('common.severity') +
': ' + ': ' +
localize('common.severities.' + severity), localize('common.severities.' + severity),
icon: { icon: 'mdi:circle-medium' }, icon: 'mdi:circle-medium',
} }
: null; : null;
return; return;
@@ -86,7 +91,7 @@ export class MediaDetailsController {
if (cameraMetadata?.title) { if (cameraMetadata?.title) {
this._heading = { this._heading = {
title: cameraMetadata.title, text: cameraMetadata.title,
}; };
return; return;
} }
@@ -121,54 +126,54 @@ export class MediaDetailsController {
...(startTime ...(startTime
? [ ? [
{ {
hint: localize('thumbnail.start'), tooltip: localize('thumbnail.start'),
icon: { icon: 'mdi:calendar-clock-outline' }, icon: 'mdi:calendar-clock-outline',
title: format(startTime, 'yyyy-MM-dd HH:mm:ss'), text: format(startTime, 'yyyy-MM-dd HH:mm:ss'),
}, },
] ]
: []), : []),
...(duration || inProgress ...(duration || inProgress
? [ ? [
{ {
hint: localize('thumbnail.duration'), tooltip: localize('thumbnail.duration'),
icon: { icon: 'mdi:clock-outline' }, icon: 'mdi:clock-outline',
title: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`, text: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`,
}, },
] ]
: []), : []),
...(cameraMetadata?.title ...(cameraMetadata?.title
? [ ? [
{ {
hint: localize('thumbnail.camera'), tooltip: localize('thumbnail.camera'),
title: cameraMetadata.title, text: cameraMetadata.title,
icon: { icon: 'mdi:cctv' }, icon: 'mdi:cctv',
}, },
] ]
: []), : []),
...(where ...(where
? [ ? [
{ {
hint: localize('thumbnail.where'), tooltip: localize('thumbnail.where'),
title: where, text: where,
icon: { icon: 'mdi:map-marker-outline' }, icon: 'mdi:map-marker-outline',
}, },
] ]
: []), : []),
...(tags ...(tags
? [ ? [
{ {
hint: localize('thumbnail.tag'), tooltip: localize('thumbnail.tag'),
title: tags, text: tags,
icon: { icon: 'mdi:tag' }, icon: 'mdi:tag',
}, },
] ]
: []), : []),
...(seekString ...(seekString
? [ ? [
{ {
hint: localize('thumbnail.seek'), tooltip: localize('thumbnail.seek'),
title: seekString, text: seekString,
icon: { icon: 'mdi:clock-fast' }, icon: 'mdi:clock-fast',
}, },
] ]
: []), : []),
@@ -183,10 +188,10 @@ export class MediaDetailsController {
...(includeTitle && itemTitle ...(includeTitle && itemTitle
? [ ? [
{ {
title: itemTitle, text: itemTitle,
...(details.length > 0 && { ...(details.length > 0 && {
icon: { icon: 'mdi:rename' }, icon: 'mdi:rename',
hint: localize('thumbnail.title'), tooltip: localize('thumbnail.title'),
}), }),
}, },
] ]
@@ -195,20 +200,15 @@ export class MediaDetailsController {
]; ];
} }
public getHeading(): MetadataField | null { public getHeading(): NotificationDetail | null {
return this._heading; return this._heading;
} }
public getDetails(): MetadataField[] { public getDetails(): NotificationDetail[] {
return this._details; return this._details;
} }
/** public getNotification(context?: NotificationControlsContext): Notification {
* Get an overlay message for the item.
* @param context Optional context to include controls.
* @returns An OverlayMessage.
*/
public getMessage(context?: OverlayControlsContext): OverlayMessage {
return { return {
heading: this._heading ?? undefined, heading: this._heading ?? undefined,
controls: context ? this._getControls(context) : undefined, controls: context ? this._getControls(context) : undefined,
@@ -219,8 +219,8 @@ export class MediaDetailsController {
}; };
} }
private _getControls(context: OverlayControlsContext): OverlayMessageControl[] { private _getControls(context: NotificationControlsContext): NotificationControl[] {
const controls: OverlayMessageControl[] = []; const controls: NotificationControl[] = [];
const item = this._item; const item = this._item;
if (!item) { if (!item) {
@@ -230,57 +230,71 @@ export class MediaDetailsController {
if (ViewItemClassifier.isReview(item)) { if (ViewItemClassifier.isReview(item)) {
const isReviewed = item.isReviewed(); const isReviewed = item.isReviewed();
controls.push({ controls.push({
title: isReviewed tooltip: isReviewed
? localize('common.set_reviews.unreviewed') ? localize('common.set_reviews.unreviewed')
: localize('common.set_reviews.reviewed'), : localize('common.set_reviews.reviewed'),
icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' }, icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
callback: async () => { actions: {
const success = await toggleReviewed( tap_action: createInternalCallbackAction(async (api) => {
item, const success = await toggleReviewed(
context.viewItemManager, item,
context.viewManagerEpoch, context.viewItemManager,
context.filterReviewed, context.viewManagerEpoch,
); context.filterReviewed,
return success ? this.getMessage(context) : null; );
if (success) {
api
.getNotificationManager()
.setNotification(this.getNotification(context));
}
}),
}, },
dismiss: false,
}); });
} }
if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) { if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) {
const isFavorite = item.isFavorite(); const isFavorite = item.isFavorite();
controls.push({ controls.push({
title: localize('thumbnail.retain_indefinitely'), tooltip: localize('thumbnail.retain_indefinitely'),
icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' }, icon: isFavorite ? 'mdi:star' : 'mdi:star-outline',
emphasis: isFavorite ? 'medium' : undefined, severity: isFavorite ? 'medium' : undefined,
callback: async () => { actions: {
const success = await toggleFavorite(item, context.viewItemManager); tap_action: createInternalCallbackAction(async (api) => {
return success ? this.getMessage(context) : null; const success = await toggleFavorite(item, context.viewItemManager);
if (success) {
api
.getNotificationManager()
.setNotification(this.getNotification(context));
}
}),
}, },
dismiss: false,
}); });
} }
if (context.capabilities?.canDownload && item.getID()) { if (context.capabilities?.canDownload && item.getID()) {
controls.push({ controls.push({
title: localize('thumbnail.download'), tooltip: localize('thumbnail.download'),
icon: { icon: 'mdi:download' }, icon: 'mdi:download',
callback: async () => { dismiss: true,
await downloadMedia(item, context.viewItemManager); actions: {
tap_action: createInternalCallbackAction(async () => {
// Close overlay message after download. await downloadMedia(item, context.viewItemManager);
return null; }),
}, },
}); });
} }
if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) { if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) {
controls.push({ controls.push({
title: localize('thumbnail.timeline'), tooltip: localize('thumbnail.timeline'),
icon: { icon: 'mdi:target' }, icon: 'mdi:target',
callback: () => { dismiss: true,
navigateToTimeline(item, context.viewManagerEpoch); actions: {
tap_action: createInternalCallbackAction(async () => {
// Close overlay after timeline navigation navigateToTimeline(item, context.viewManagerEpoch);
return null; }),
}, },
}); });
} }
+2 -2
View File
@@ -8,9 +8,9 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { IconController } from '../components-lib/icon-controller'; import { IconController } from '../components-lib/icon-controller';
import { InternalIcon } from '../config/schema/common/icon.js';
import { HomeAssistant } from '../ha/types'; import { HomeAssistant } from '../ha/types';
import iconStyle from '../scss/icon.scss'; import iconStyle from '../scss/icon.scss';
import { Icon } from '../types.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
@customElement('advanced-camera-card-icon') @customElement('advanced-camera-card-icon')
@@ -19,7 +19,7 @@ export class AdvancedCameraCardIcon extends LitElement {
public hass?: HomeAssistant; public hass?: HomeAssistant;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public icon?: Icon; public icon?: InternalIcon;
// Note: This attribute will allow non-active entity state styles (e.g. 'off', // 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 // '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 { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { NextPreviousControlConfig } from '../config/schema/common/controls/next-previous.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 { HomeAssistant } from '../ha/types.js';
import controlStyle from '../scss/next-previous-control.scss'; import controlStyle from '../scss/next-previous-control.scss';
import { Icon } from '../types.js';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.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 { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import overlayMessageStyle from '../scss/overlay-message.scss'; import { actionHandler } from '../action-handler-directive.js';
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js'; 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'; import './icon.js';
@customElement('advanced-camera-card-overlay-message') @customElement('advanced-camera-card-notification')
export class AdvancedCameraCardOverlayMessage extends LitElement { export class AdvancedCameraCardNotification extends LitElement {
@property({ attribute: false }) @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 { public connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -29,20 +41,20 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.message) { if (!this.notification) {
return; return;
} }
const heading = this.message.heading; const heading = this.notification.heading;
const details = this.message.details ?? []; const details = this.notification.details ?? [];
const text = this.message.text; const text = this.notification.text;
const controls = this.message.controls ?? []; const controls = this.notification.controls ?? [];
return html` return html`
<div class="backdrop" @click=${this._dismiss}></div> <div class="backdrop" @click=${this._dismiss}></div>
<div <div
class="message" class="notification"
${ref(this._refMessage)} ${ref(this._refNotification)}
@animationend=${this._handleAnimationEnd} @animationend=${this._handleAnimationEnd}
> >
<div class="details"> <div class="details">
@@ -64,60 +76,70 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
`; `;
} }
private _renderControl(control: OverlayMessageControl): TemplateResult { private _renderControl(control: NotificationControl): TemplateResult {
const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : ''; const severityClass = control.severity ? `severity-${control.severity}` : '';
return html` return html`
<div <div
class="control ${emphasisClass}" class="control ${severityClass}"
title=${control.title} title=${control.tooltip ?? ''}
@click=${async () => this._handleControlClick(control)} .actionHandler=${actionHandler({
hasHold: hasAction(control.actions?.hold_action),
hasDoubleClick: hasAction(control.actions?.double_tap_action),
})}
@action=${(ev: CustomEvent) => this._handleControlAction(ev, control)}
> >
${control.icon ${control.icon
? html`<advanced-camera-card-icon ? html`<advanced-camera-card-icon
.icon=${control.icon} .icon=${{ icon: control.icon }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
: ''} : ''}
</div> </div>
`; `;
} }
private async _handleControlClick(control: OverlayMessageControl): Promise<void> { private _handleControlAction(
const result = await control.callback(); ev: CustomEvent<{ action: string }>,
if (result === null) { control: NotificationControl,
// null = close the message ): void {
stopEventFromActivatingCardWideActions(ev);
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
if (action) {
dispatchActionExecutionRequest(this, {
actions: arrayify(action),
});
}
if (control.dismiss !== false) {
this._dismiss(); 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 = { const classes = {
detail: true, detail: true,
heading: isHeading, heading: isHeading,
[`emphasis-${detail.emphasis}`]: !!detail.emphasis, [`severity-${detail.severity}`]: !!detail.severity,
}; };
return html` return html`
<div class="${classMap(classes)}"> <div class="${classMap(classes)}">
${detail.icon ${detail.icon
? html`<advanced-camera-card-icon ? html`<advanced-camera-card-icon
title=${detail.hint ?? ''} title=${detail.tooltip ?? ''}
.icon=${detail.icon} .icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
: ''} : ''}
<span title=${detail.title}>${detail.title}</span> <span title=${detail.text}>${detail.text}</span>
</div> </div>
`; `;
} }
private _dismiss = (): void => { private _dismiss = (): void => {
this._refMessage.value?.classList.add('exiting'); this._refNotification.value?.classList.add('exiting');
}; };
private _handleAnimationEnd = (ev: AnimationEvent): void => { private _handleAnimationEnd = (ev: AnimationEvent): void => {
if (ev.animationName === 'slideDown') { if (ev.animationName === 'slideDown') {
dispatchDismissOverlayMessageEvent(this); dispatchDismissNotificationEvent(this);
} }
}; };
@@ -136,12 +158,12 @@ export class AdvancedCameraCardOverlayMessage extends LitElement {
}; };
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(overlayMessageStyle); return unsafeCSS(notificationStyle);
} }
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage; 'advanced-camera-card-notification': AdvancedCameraCardNotification;
} }
} }
+2 -2
View File
@@ -8,6 +8,7 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.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 { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js'; import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
import { computeDomain } from '../../ha/compute-domain.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 { EntityRegistryManager } from '../../ha/registry/entity/types.js';
import { HomeAssistant } from '../../ha/types.js'; import { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss'; import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js';
import { createSelectOptionAction } from '../../utils/action.js'; import { createSelectOptionAction } from '../../utils/action.js';
import '../icon.js'; import '../icon.js';
import './index.js'; import './index.js';
@@ -37,7 +37,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
private _optionTitles?: Record<string, string>; private _optionTitles?: Record<string, string>;
private _generatedSubmenuItems?: MenuSubmenuItem[]; private _generatedSubmenuItems?: MenuSubmenuItem[];
private _generatedIcon?: Icon; private _generatedIcon?: InternalIcon;
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
// No need to update the submenu unless the select entity has changed. // No need to update the submenu unless the select entity has changed.
+10 -6
View File
@@ -8,11 +8,12 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager'; import { CameraManager } from '../../camera-manager/manager';
import { MediaDetailsController } from '../../components-lib/media/details-controller'; import { MediaDetailsController } from '../../components-lib/media/details-controller';
import { NotificationDetail } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss'; import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
import { MetadataField } from '../../types';
import { ViewItem } from '../../view/item'; import { ViewItem } from '../../view/item';
import '../icon'; import '../icon';
@@ -42,7 +43,10 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
const heading = this._controller.getHeading(); const heading = this._controller.getHeading();
const details = this._controller.getDetails(); const details = this._controller.getDetails();
const renderDetail = (detail: MetadataField, heading = false): TemplateResult => { const renderDetail = (
detail: NotificationDetail,
heading = false,
): TemplateResult => {
return html`<div return html`<div
class=${classMap({ class=${classMap({
heading, heading,
@@ -50,12 +54,12 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
> >
${detail.icon ${detail.icon
? html` <advanced-camera-card-icon ? html` <advanced-camera-card-icon
severity=${detail.emphasis} severity=${ifDefined(detail.severity)}
title=${detail.hint ?? ''} title=${detail.tooltip ?? ''}
.icon=${detail.icon} .icon=${{ icon: detail.icon }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
: ''} : ''}
<span title=${detail.title}>${detail.title}</span> <span title=${detail.text}>${detail.text}</span>
</div>`; </div>`;
}; };
+14 -8
View File
@@ -9,24 +9,27 @@ import {
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../../../camera-manager/manager'; import { CameraManager } from '../../../camera-manager/manager';
import { dispatchActionExecutionRequest } from '../../../card-controller/actions/utils/execution-request';
import { ViewItemManager } from '../../../card-controller/view/item-manager'; import { ViewItemManager } from '../../../card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../card-controller/view/types'; import { ViewManagerEpoch } from '../../../card-controller/view/types';
import { import {
MediaDetailsController, MediaDetailsController,
OverlayControlsContext, NotificationControlsContext,
} from '../../../components-lib/media/details-controller'; } from '../../../components-lib/media/details-controller';
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller'; import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
import { HomeAssistant } from '../../../ha/types'; import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize'; import { localize } from '../../../localize/localize';
import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss'; import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss';
import { stopEventFromActivatingCardWideActions } from '../../../utils/action'; import {
createNotificationAction,
stopEventFromActivatingCardWideActions,
} from '../../../utils/action';
import { import {
downloadMedia, downloadMedia,
navigateToTimeline, navigateToTimeline,
toggleFavorite, toggleFavorite,
toggleReviewed, toggleReviewed,
} from '../../../utils/media-actions'; } from '../../../utils/media-actions';
import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message';
import { ViewItem } from '../../../view/item'; import { ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier'; import { ViewItemClassifier } from '../../../view/item-classifier';
import '../../icon.js'; import '../../icon.js';
@@ -80,7 +83,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
} }
} }
private _getControlContext(): OverlayControlsContext { private _getControlContext(): NotificationControlsContext {
return { return {
hass: this.hass, hass: this.hass,
viewItemManager: this.viewItemManager, viewItemManager: this.viewItemManager,
@@ -207,10 +210,13 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
const detailsController = new MediaDetailsController(); const detailsController = new MediaDetailsController();
detailsController.calculate(this.cameraManager, this.item); detailsController.calculate(this.cameraManager, this.item);
dispatchShowOverlayMessageEvent( dispatchActionExecutionRequest(this, {
this, actions: [
detailsController.getMessage(this._getControlContext()), createNotificationAction(
); detailsController.getNotification(this._getControlContext()),
),
],
});
}} }}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
: ''} : ''}
+53 -2
View File
@@ -19,9 +19,26 @@ import { substreamSelectActionConfigSchema } from './custom/substream-select';
import { viewActionConfigSchema } from './custom/view'; import { viewActionConfigSchema } from './custom/view';
import { stockActionSchema } from './stock/types'; 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 // 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< export type StatusBarActionConfig = z.infer<
typeof advancedCameraCardCustomActionsBaseSchema typeof advancedCameraCardCustomActionsBaseSchema
> & { > & {
@@ -46,6 +63,7 @@ const advancedCameraCardCustomActionSchema = z.union([
internalCallbackActionConfigSchema, internalCallbackActionConfigSchema,
logActionConfigSchema, logActionConfigSchema,
mediaPlayerActionConfigSchema, mediaPlayerActionConfigSchema,
notificationActionConfigSchema,
ptzActionConfigSchema, ptzActionConfigSchema,
ptzControlsActionConfigSchema, ptzControlsActionConfigSchema,
ptzDigitalActionConfigSchema, ptzDigitalActionConfigSchema,
@@ -66,6 +84,7 @@ export const actionConfigSchema = z.union([
advancedCameraCardCustomActionSchema, advancedCameraCardCustomActionSchema,
]); ]);
export type ActionConfig = z.infer<typeof actionConfigSchema>; export type ActionConfig = z.infer<typeof actionConfigSchema>;
export const actionsBaseSchema = z export const actionsBaseSchema = z
.object({ .object({
tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
@@ -90,6 +109,38 @@ export const actionsSchema = z.object({
actions: actionsBaseSchema.optional(), 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 // 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, severity: statusBarItemDefault,
technology: statusBarItemDefault, technology: statusBarItemDefault,
title: statusBarItemDefault, title: statusBarItemDefault,
upgrade: statusBarItemDefault,
}, },
position: 'bottom' as const, position: 'bottom' as const,
style: 'popup' as const, style: 'popup' as const,
@@ -53,6 +54,7 @@ export const statusBarConfigSchema = z
), ),
severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity), severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity),
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title), title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
upgrade: statusBarItemBaseSchema.default(statusBarConfigDefault.items.upgrade),
}) })
.default(statusBarConfigDefault.items), .default(statusBarConfigDefault.items),
}) })
+1
View File
@@ -3110,6 +3110,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._renderStatusBarItem('resolution') /* */} ${this._renderStatusBarItem('resolution') /* */}
${this._renderStatusBarItem('technology') /* */} ${this._renderStatusBarItem('technology') /* */}
${this._renderStatusBarItem('engine') /* */} ${this._renderStatusBarItem('engine') /* */}
${this._renderStatusBarItem('upgrade') /* */}
</div> </div>
` `
: ''} : ''}
+9 -1
View File
@@ -6,6 +6,7 @@
"common": { "common": {
"advanced_camera_card": "Advanced Camera Card", "advanced_camera_card": "Advanced Camera Card",
"advanced_camera_card_description": "An Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card",
"dismiss": "Dismiss",
"folder": "Folder", "folder": "Folder",
"live": "Live", "live": "Live",
"no_folder": "No folder to display", "no_folder": "No folder to display",
@@ -557,7 +558,8 @@
"priority": "Item priority", "priority": "Item priority",
"resolution": "Resolution", "resolution": "Resolution",
"technology": "Technology", "technology": "Technology",
"title": "Title" "title": "Title",
"upgrade": "Configuration upgrade notification"
}, },
"popup_seconds": "Status bar popup seconds", "popup_seconds": "Status bar popup seconds",
"position": "Status bar position", "position": "Status bar position",
@@ -821,6 +823,12 @@
"media_viewer": { "media_viewer": {
"unseekable": "Seek time not found in media" "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": { "thumbnail": {
"camera": "Camera", "camera": "Camera",
"download": "Download media", "download": "Download media",
@@ -8,7 +8,7 @@
align-items: flex-end; align-items: flex-end;
padding: 24px; padding: 24px;
pointer-events: none; pointer-events: none;
z-index: $z-index-overlay-message; z-index: $z-index-notification;
} }
.backdrop { .backdrop {
@@ -17,7 +17,7 @@
pointer-events: auto; pointer-events: auto;
} }
.message { .notification {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -30,23 +30,22 @@
padding-right: 48px; padding-right: 48px;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--advanced-camera-card-overlay-message-scrollbar-color) scrollbar-color: var(--advanced-camera-card-notification-scrollbar-color) transparent;
transparent;
// Glassmorphism background // Glassmorphism background
background: var(--advanced-camera-card-overlay-message-background); background: var(--advanced-camera-card-notification-background);
backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter); backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter);
-webkit-backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter); -webkit-backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter);
// Borders & Edge highlights // Borders & Edge highlights
border: var(--advanced-camera-card-overlay-message-border); border: var(--advanced-camera-card-notification-border);
border-top: var(--advanced-camera-card-overlay-message-border-top); border-top: var(--advanced-camera-card-notification-border-top);
border-radius: var(--advanced-camera-card-border-radius-final); border-radius: var(--advanced-camera-card-border-radius-final);
// Shadows // Shadows
box-shadow: var( box-shadow: var(
--advanced-camera-card-box-shadow-override, --advanced-camera-card-box-shadow-override,
var(--advanced-camera-card-overlay-message-box-shadow) var(--advanced-camera-card-notification-box-shadow)
); );
pointer-events: auto; pointer-events: auto;
@@ -56,7 +55,7 @@
} }
// Exit animation // Exit animation
.message.exiting { .notification.exiting {
animation: slideDown 0.25s ease-out forwards; animation: slideDown 0.25s ease-out forwards;
} }
@@ -99,12 +98,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--advanced-camera-card-overlay-message-icon-color); color: var(--advanced-camera-card-notification-icon-color);
filter: drop-shadow( filter: drop-shadow(
0 0 8px 0 0 8px
color-mix( color-mix(
in oklab, in oklab,
var(--advanced-camera-card-overlay-message-icon-color), var(--advanced-camera-card-notification-icon-color),
transparent 60% transparent 60%
) )
); );
@@ -119,7 +118,7 @@
font-size: 15px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; 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; word-break: break-word;
} }
@@ -135,7 +134,7 @@
background: color-mix( background: color-mix(
in srgb, in srgb,
var(--advanced-camera-card-overlay-message-close-color), var(--advanced-camera-card-notification-close-color),
transparent 92% transparent 92%
); );
} }
@@ -150,7 +149,7 @@
color: color-mix( color: color-mix(
in srgb, in srgb,
var(--advanced-camera-card-overlay-message-close-color), var(--advanced-camera-card-notification-close-color),
transparent 40% transparent 40%
); );
@@ -166,17 +165,17 @@
} }
// Base control hover color transition (from dimmed to full). // Base control hover color transition (from dimmed to full).
&:not([class^='emphasis-']):hover { &:not([class^='severity-']):hover {
color: var(--advanced-camera-card-overlay-message-close-color); color: var(--advanced-camera-card-notification-close-color);
} }
&.emphasis-low { &.severity-low {
color: var(--advanced-camera-card-foreground-severity-low-color); color: var(--advanced-camera-card-foreground-severity-low-color);
} }
&.emphasis-medium { &.severity-medium {
color: var(--advanced-camera-card-foreground-severity-medium-color); color: var(--advanced-camera-card-foreground-severity-medium-color);
} }
&.emphasis-high { &.severity-high {
color: var(--advanced-camera-card-foreground-severity-high-color); color: var(--advanced-camera-card-foreground-severity-high-color);
} }
} }
@@ -194,12 +193,12 @@
background: color-mix( background: color-mix(
in srgb, in srgb,
var(--advanced-camera-card-overlay-message-close-color), var(--advanced-camera-card-notification-close-color),
transparent 95% transparent 95%
); );
color: color-mix( color: color-mix(
in srgb, in srgb,
var(--advanced-camera-card-overlay-message-close-color), var(--advanced-camera-card-notification-close-color),
transparent 40% transparent 40%
); );
@@ -210,10 +209,10 @@
.close:hover { .close:hover {
background: color-mix( background: color-mix(
in srgb, in srgb,
var(--advanced-camera-card-overlay-message-close-color), var(--advanced-camera-card-notification-close-color),
transparent 90% transparent 90%
); );
color: var(--advanced-camera-card-overlay-message-close-color); color: var(--advanced-camera-card-notification-close-color);
} }
.close advanced-camera-card-icon { .close advanced-camera-card-icon {
@@ -233,7 +232,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: var(--advanced-camera-card-overlay-message-text-color); color: var(--advanced-camera-card-notification-text-color);
font-size: 14px; font-size: 14px;
line-height: 1.4; 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); color: var(--advanced-camera-card-foreground-severity-low-color);
opacity: 1; opacity: 1;
} }
&.emphasis-medium advanced-camera-card-icon { &.severity-medium advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-medium-color); color: var(--advanced-camera-card-foreground-severity-medium-color);
opacity: 1; opacity: 1;
} }
&.emphasis-high advanced-camera-card-icon { &.severity-high advanced-camera-card-icon {
color: var(--advanced-camera-card-foreground-severity-high-color); color: var(--advanced-camera-card-foreground-severity-high-color);
opacity: 1; opacity: 1;
} }
@@ -282,10 +281,10 @@
border-top: 1px solid border-top: 1px solid
color-mix( color-mix(
in srgb, in srgb,
var(--advanced-camera-card-overlay-message-text-color), var(--advanced-camera-card-notification-text-color),
transparent 80% transparent 80%
); );
color: var(--advanced-camera-card-overlay-message-text-color); color: var(--advanced-camera-card-notification-text-color);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap; white-space: pre-wrap;
+1
View File
@@ -64,6 +64,7 @@
.item.action { .item.action {
cursor: pointer; cursor: pointer;
pointer-events: auto;
} }
img.item, img.item,
+12 -12
View File
@@ -286,24 +286,24 @@
transparent 20% transparent 20%
); );
/****************** /**************
* Overlay Message * Notification
******************/ **************/
--advanced-camera-card-overlay-message-background: linear-gradient( --advanced-camera-card-notification-background: linear-gradient(
135deg, 135deg,
rgba(35, 35, 35, 0.75) 0%, rgba(35, 35, 35, 0.75) 0%,
rgba(15, 15, 15, 0.65) 100% rgba(15, 15, 15, 0.65) 100%
); );
--advanced-camera-card-overlay-message-backdrop-filter: blur(20px) saturate(180%); --advanced-camera-card-notification-backdrop-filter: blur(20px) saturate(180%);
--advanced-camera-card-overlay-message-border: 1px solid rgba(255, 255, 255, 0.1); --advanced-camera-card-notification-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-notification-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-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); 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-notification-text-color: #ffffff;
--advanced-camera-card-overlay-message-icon-color: var( --advanced-camera-card-notification-icon-color: var(
--advanced-camera-card-foreground-primary --advanced-camera-card-foreground-primary
); );
--advanced-camera-card-overlay-message-scrollbar-color: rgba(255, 255, 255, 0.2); --advanced-camera-card-notification-scrollbar-color: rgba(255, 255, 255, 0.2);
--advanced-camera-card-overlay-message-close-color: #ffffff; --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. // Menu border colors need to re-adjust based on redefined variable.
--wa-color-surface-border: var(--ha-color-border-neutral-quiet); --wa-color-surface-border: var(--ha-color-border-neutral-quiet);
/****************** /**************
* Overlay Message * Notification
******************/ **************/
--advanced-camera-card-overlay-message-background: linear-gradient( --advanced-camera-card-notification-background: linear-gradient(
135deg, 135deg,
rgba(255, 255, 255, 0.85) 0%, rgba(255, 255, 255, 0.85) 0%,
rgba(235, 235, 235, 0.75) 100% rgba(235, 235, 235, 0.75) 100%
); );
--advanced-camera-card-overlay-message-text-color: var(--primary-text-color); --advanced-camera-card-notification-text-color: var(--primary-text-color);
--advanced-camera-card-overlay-message-border: 1px solid rgba(0, 0, 0, 0.3); --advanced-camera-card-notification-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-notification-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-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); 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-notification-scrollbar-color: rgba(0, 0, 0, 0.5);
--advanced-camera-card-overlay-message-close-color: #000000; --advanced-camera-card-notification-close-color: #000000;
--app-header-text-color: white; --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) // Need menu to render above drawer (so the drawer button is below menu-submenus)
$z-index-menu: 4; $z-index-menu: 4;
$z-index-overlay-message: 4; $z-index-notification: 4;
$z-index-drawer: 3; $z-index-drawer: 3;
$z-index-status-bar: 2; $z-index-status-bar: 2;
-34
View File
@@ -1,7 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import type { EffectOptions } from './card-controller/effects/types'; import type { EffectOptions } from './card-controller/effects/types';
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
import { Severity } from './severity';
// UI-facing media types for galleries and views. // UI-facing media types for galleries and views.
export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const; export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const;
@@ -64,24 +63,6 @@ export interface Message {
url?: MessageURL; 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 & { export type WebkitHTMLVideoElement = HTMLVideoElement & {
webkitDisplayingFullscreen: boolean; webkitDisplayingFullscreen: boolean;
webkitSupportsFullscreen: boolean; webkitSupportsFullscreen: boolean;
@@ -182,21 +163,6 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [
'trigger', 'trigger',
] as const; ] 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 { export interface Interaction {
action: string; 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 { SubstreamSelectActionConfig } from '../config/schema/actions/custom/substream-select.js';
import { ViewActionConfig } from '../config/schema/actions/custom/view.js'; import { ViewActionConfig } from '../config/schema/actions/custom/view.js';
import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js'; import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js';
import type { Notification } from '../config/schema/actions/types.js';
import { import {
ActionConfig, ActionConfig,
ActionsConfig, Actions,
AdvancedCameraCardCustomActionConfig, AdvancedCameraCardCustomActionConfig,
NotificationActionConfig,
} from '../config/schema/actions/types.js'; } from '../config/schema/actions/types.js';
import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js'; import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js';
import { PTZControlType } from '../config/schema/common/controls/ptz.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'). * Get an action configuration given a config and an interaction (e.g. 'tap').
* @param interaction The interaction: `tap`, `hold` or `double_tap` * @param interaction The interaction: `tap`, `hold` or `double_tap`
@@ -278,7 +294,7 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig
*/ */
export function getActionConfigGivenAction( export function getActionConfigGivenAction(
interaction?: string, interaction?: string,
config?: ActionsConfig | null, config?: Actions | null,
): ActionConfig | ActionConfig[] | null { ): ActionConfig | ActionConfig[] | null {
if (!interaction || !config) { if (!interaction || !config) {
return null; 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); await action.execute(api);
expect(api.getOverlayMessageManager().setMessage).toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled();
}); });
it('should not handle info action without media', async () => { it('should not handle info action without media', async () => {
@@ -45,6 +45,6 @@ describe('InfoAction', () => {
await action.execute(api); 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 { MuteAction } from '../../../src/card-controller/actions/actions/mute';
import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate'; import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate';
import { NoneAction } from '../../../src/card-controller/actions/actions/none'; 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 { 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 { 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 { PlayAction } from '../../../src/card-controller/actions/actions/play';
import { PTZAction } from '../../../src/card-controller/actions/actions/ptz'; import { PTZAction } from '../../../src/card-controller/actions/actions/ptz';
import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls'; import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls';
@@ -126,6 +127,13 @@ describe('ActionFactory', () => {
}, },
LogAction, LogAction,
], ],
[
{
advanced_camera_card_action: 'notification' as const,
notification: { text: 'test' },
},
NotificationAction,
],
[ [
{ {
advanced_camera_card_action: 'media_player' as const, advanced_camera_card_action: 'media_player' as const,
@@ -121,6 +121,27 @@ describe('ConfigManager', () => {
expect(manager.getConfig()).toBeNull(); expect(manager.getConfig()).toBeNull();
expect(manager.getNonOverriddenConfig()).toBeNull(); expect(manager.getNonOverriddenConfig()).toBeNull();
expect(manager.getRawConfig()).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', () => { 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 { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
import { MessageManager } from '../../src/card-controller/message-manager'; import { MessageManager } from '../../src/card-controller/message-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-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 { PIPManager } from '../../src/card-controller/pip-manager';
import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-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/media-player-manager');
vi.mock('../../src/card-controller/message-manager'); vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-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/pip-manager');
vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager'); vi.mock('../../src/card-controller/status-bar-item-manager');
@@ -226,9 +226,9 @@ describe('CardController', () => {
); );
}); });
it('getOverlayMessageManager', () => { it('getNotificationManager', () => {
expect(createController().getOverlayMessageManager()).toBe( expect(createController().getNotificationManager()).toBe(
vi.mocked(OverlayMessageManager).mock.instances[0], 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', () => { describe('severity', () => {
it('should have severity in a viewer view', () => { it('should have severity in a viewer view', () => {
const manager = new StatusBarItemManager(createCardAPI()); const manager = new StatusBarItemManager(createCardAPI());
@@ -1,18 +1,39 @@
import { format } from 'date-fns'; 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 { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager'; 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 { ViewItemManager } from '../../../src/card-controller/view/item-manager';
import { ViewManagerEpoch } from '../../../src/card-controller/view/types'; import { ViewManagerEpoch } from '../../../src/card-controller/view/types';
import { import {
MediaDetailsController, MediaDetailsController,
OverlayControlsContext, NotificationControlsContext,
} from '../../../src/components-lib/media/details-controller'; } 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 { formatDateAndTime } from '../../../src/utils/basic';
import { downloadMedia, navigateToTimeline } from '../../../src/utils/media-actions';
import { ViewFolder, ViewMediaType } from '../../../src/view/item'; import { ViewFolder, ViewMediaType } from '../../../src/view/item';
import { createCardAPI, createFolder, TestViewMedia } from '../../test-utils'; 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('MediaDetailsController', () => {
describe('should set heading', () => { describe('should set heading', () => {
it('should set heading on event with what, tags and score', () => { it('should set heading on event with what, tags and score', () => {
@@ -24,7 +45,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); 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', () => { it('should set heading on event with tags', () => {
@@ -34,7 +55,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); 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', () => { it('should set heading on event with what', () => {
@@ -44,7 +65,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); 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', () => { it('should set null heading on event with no other information', () => {
@@ -73,7 +94,7 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(cameraManager, item); 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', () => { it('should set heading on recording without camera metadata', () => {
@@ -106,9 +127,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: 'Test Event', text: 'Test Event',
icon: { icon: 'mdi:rename' }, icon: 'mdi:rename',
hint: 'Title', tooltip: 'Title',
}); });
}); });
@@ -121,7 +142,7 @@ describe('MediaDetailsController', () => {
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toEqual([ expect(controller.getDetails()).toEqual([
{ {
title: 'Test Event', text: 'Test Event',
}, },
]); ]);
}); });
@@ -136,7 +157,7 @@ describe('MediaDetailsController', () => {
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).not.toContainEqual( expect(controller.getDetails()).not.toContainEqual(
expect.objectContaining({ 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) // Use formatDateAndTime to generate expected value (formats in local time with seconds)
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: formatDateAndTime(startTime, true), text: formatDateAndTime(startTime, true),
hint: 'Start', tooltip: 'Start',
icon: { icon: 'mdi:calendar-clock-outline' }, icon: 'mdi:calendar-clock-outline',
}); });
}); });
@@ -169,9 +190,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: '1m 0s', text: '1m 0s',
hint: 'Duration', tooltip: 'Duration',
icon: { icon: 'mdi:clock-outline' }, icon: 'mdi:clock-outline',
}); });
}); });
@@ -185,9 +206,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: 'In Progress', text: 'In Progress',
hint: 'Duration', tooltip: 'Duration',
icon: { icon: 'mdi:clock-outline' }, icon: 'mdi:clock-outline',
}); });
}); });
@@ -201,9 +222,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: '1m 0s In Progress', text: '1m 0s In Progress',
hint: 'Duration', tooltip: 'Duration',
icon: { icon: 'mdi:clock-outline' }, icon: 'mdi:clock-outline',
}); });
}); });
}); });
@@ -222,9 +243,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(cameraManager, item); controller.calculate(cameraManager, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: 'Camera Title', text: 'Camera Title',
hint: 'Camera', tooltip: 'Camera',
icon: { icon: 'mdi:cctv' }, icon: 'mdi:cctv',
}); });
}); });
@@ -237,9 +258,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: 'Where1, Where2', text: 'Where1, Where2',
hint: 'Where', tooltip: 'Where',
icon: { icon: 'mdi:map-marker-outline' }, icon: 'mdi:map-marker-outline',
}); });
}); });
@@ -252,9 +273,9 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: 'Tag1, Tag2', text: 'Tag1, Tag2',
hint: 'Tag', tooltip: 'Tag',
icon: { icon: 'mdi:tag' }, icon: 'mdi:tag',
}); });
}); });
@@ -267,9 +288,9 @@ describe('MediaDetailsController', () => {
// Use format() to generate expected value (formats in local time) // Use format() to generate expected value (formats in local time)
expect(controller.getDetails()).toContainEqual({ expect(controller.getDetails()).toContainEqual({
title: format(seekTime, 'HH:mm:ss'), text: format(seekTime, 'HH:mm:ss'),
hint: 'Seek', tooltip: 'Seek',
icon: { icon: 'mdi:clock-fast' }, icon: 'mdi:clock-fast',
}); });
}); });
it('should set heading on review', () => { it('should set heading on review', () => {
@@ -282,10 +303,10 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const heading = controller.getHeading(); const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title'); expect(heading?.text).toBe('Review Title');
expect(heading?.emphasis).toBe('high'); expect(heading?.severity).toBe('high');
expect(heading?.icon).toEqual({ icon: 'mdi:circle-medium' }); expect(heading?.icon).toBe('mdi:circle-medium');
expect(heading?.hint).toBe('Severity: High'); expect(heading?.tooltip).toBe('Severity: High');
}); });
it('should set heading on review without severity', () => { it('should set heading on review without severity', () => {
@@ -298,8 +319,8 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const heading = controller.getHeading(); const heading = controller.getHeading();
expect(heading?.title).toBe('Review Title'); expect(heading?.text).toBe('Review Title');
expect(heading?.emphasis).toBeUndefined(); expect(heading?.severity).toBeUndefined();
}); });
it('should set null heading on review with no title', () => { 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(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('should get message', () => { it('should get notification', () => {
const item = new TestViewMedia({ const item = new TestViewMedia({
title: 'Test Title', title: 'Test Title',
what: ['person'], what: ['person'],
@@ -336,25 +357,25 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(); const notification = controller.getNotification();
expect(message.heading?.title).toBe('Person'); expect(notification.heading?.text).toBe('Person');
expect(message.details).toContainEqual({ expect(notification.details).toContainEqual({
title: 'Test Title', 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 item = new ViewFolder(createFolder(), []);
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(); const notification = controller.getNotification();
expect(message.text).toBeUndefined(); expect(notification.text).toBeUndefined();
}); });
it('should get message with null description', () => { it('should get notification with null description', () => {
const item = new TestViewMedia({ const item = new TestViewMedia({
description: null, description: null,
}); });
@@ -362,11 +383,11 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(); const notification = controller.getNotification();
expect(message.text).toBeUndefined(); expect(notification.text).toBeUndefined();
}); });
it('should get message with controls', async () => { it('should get notification with controls', async () => {
const item = new TestViewMedia({ const item = new TestViewMedia({
title: 'Test Title', title: 'Test Title',
mediaType: ViewMediaType.Review, mediaType: ViewMediaType.Review,
@@ -390,45 +411,52 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(context); const notification = controller.getNotification(context);
const controls = message.controls; const controls = notification.controls;
assert(controls);
expect(controls).toHaveLength(4); expect(controls).toHaveLength(4);
vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {});
// 1. Review control // 1. Review control
expect(controls?.[0].title).toBe('Mark as reviewed'); expect(controls?.[0].tooltip).toBe('Mark as reviewed');
const reviewResult = await controls?.[0].callback?.(); expect(controls?.[0].dismiss).toBe(false);
expect(reviewResult).not.toBeNull(); await executeControlAction(controls[0], cardAPI);
expect(cardAPI.getNotificationManager().setNotification).toHaveBeenCalled();
// 1b. Review control (failure) // 1b. Review control (failure)
vi.mocked(cardAPI.getNotificationManager().setNotification).mockClear();
viewItemManager.reviewMedia.mockRejectedValue(new Error('fail')); viewItemManager.reviewMedia.mockRejectedValue(new Error('fail'));
await executeControlAction(controls[0], cardAPI);
const reviewFailureResult = await controls?.[0].callback?.(); expect(cardAPI.getNotificationManager().setNotification).not.toHaveBeenCalled();
expect(reviewFailureResult).toBeNull();
// 2. Favorite control // 2. Favorite control
expect(controls?.[1].title).toBe('Media will be indefinitely retained'); expect(controls?.[1].tooltip).toBe('Media will be indefinitely retained');
const favoriteResult = await controls?.[1].callback?.(); expect(controls?.[1].dismiss).toBe(false);
expect(favoriteResult).not.toBeNull(); await executeControlAction(controls[1], cardAPI);
expect(cardAPI.getNotificationManager().setNotification).toHaveBeenCalled();
// 2b. Favorite control (failure) // 2b. Favorite control (failure)
vi.mocked(cardAPI.getNotificationManager().setNotification).mockClear();
viewItemManager.favorite.mockRejectedValue(new Error('fail')); viewItemManager.favorite.mockRejectedValue(new Error('fail'));
const favoriteFailureResult = await controls?.[1].callback?.(); await executeControlAction(controls[1], cardAPI);
expect(favoriteFailureResult).toBeNull(); expect(cardAPI.getNotificationManager().setNotification).not.toHaveBeenCalled();
// 3. Download control // 3. Download control
expect(controls?.[2].title).toBe('Download media'); expect(controls?.[2].tooltip).toBe('Download media');
const downloadResult = await controls?.[2].callback?.(); expect(controls?.[2].dismiss).toBe(true);
expect(downloadResult).toBeNull(); vi.mocked(downloadMedia).mockResolvedValue(true);
await executeControlAction(controls[2], cardAPI);
expect(downloadMedia).toHaveBeenCalledWith(item, viewItemManager);
// 4. Timeline control // 4. Timeline control
expect(controls?.[3].title).toBe('See media in timeline'); expect(controls?.[3].tooltip).toBe('See media in timeline');
const timelineResult = await controls?.[3].callback?.(); expect(controls?.[3].dismiss).toBe(true);
expect(timelineResult).toBeNull(); 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({ const item = new TestViewMedia({
mediaType: ViewMediaType.Review, mediaType: ViewMediaType.Review,
reviewed: true, reviewed: true,
@@ -444,18 +472,18 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(context); const notification = controller.getNotification(context);
const controls = message.controls; const controls = notification.controls;
expect(controls).toHaveLength(2); expect(controls).toHaveLength(2);
expect(controls?.[0].title).toBe('Mark as unreviewed'); expect(controls?.[0].tooltip).toBe('Mark as unreviewed');
expect(controls?.[0].icon).toEqual({ icon: 'mdi:check-circle' }); expect(controls?.[0].icon).toBe('mdi:check-circle');
expect(controls?.[1].emphasis).toBe('medium'); expect(controls?.[1].severity).toBe('medium');
expect(controls?.[1].icon).toEqual({ icon: 'mdi:star' }); 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({ const item = new TestViewMedia({
id: null, id: null,
}); });
@@ -469,11 +497,11 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(context); const notification = controller.getNotification(context);
expect(message.controls).toHaveLength(0); 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({ const item = new TestViewMedia({
id: 'id', id: 'id',
}); });
@@ -482,8 +510,8 @@ describe('MediaDetailsController', () => {
const controller = new MediaDetailsController(); const controller = new MediaDetailsController();
controller.calculate(null, item); controller.calculate(null, item);
const message = controller.getMessage(context); const notification = controller.getNotification(context);
expect(message.controls).toHaveLength(0); expect(notification.controls).toHaveLength(0);
}); });
it('should get empty controls when item is null', () => { 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. // Use cast to unknown first to avoid any-related lint errors.
const controls = ( const controls = (
controller as unknown as { controller as unknown as {
_getControls: (context: OverlayControlsContext) => OverlayMessageControl[]; _getControls: (context: NotificationControlsContext) => NotificationControl[];
} }
)._getControls({}); )._getControls({});
expect(controls).toEqual([]); 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, enabled: true,
priority: 50, priority: 50,
}, },
upgrade: {
enabled: true,
priority: 50,
},
}, },
popup_seconds: 3, popup_seconds: 3,
position: 'bottom', position: 'bottom',
@@ -1066,6 +1070,30 @@ describe('config defaults', () => {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'mute', 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', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'pause', 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 { MediaPlayerManager } from '../src/card-controller/media-player-manager';
import { MessageManager } from '../src/card-controller/message-manager'; import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-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 { PIPManager } from '../src/card-controller/pip-manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-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.getMediaPlayerManager.mockReturnValue(mock<MediaPlayerManager>());
api.getMessageManager.mockReturnValue(mock<MessageManager>()); api.getMessageManager.mockReturnValue(mock<MessageManager>());
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>()); api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getOverlayMessageManager.mockReturnValue(mock<OverlayMessageManager>()); api.getNotificationManager.mockReturnValue(mock<NotificationManager>());
api.getPIPManager.mockReturnValue(mock<PIPManager>()); api.getPIPManager.mockReturnValue(mock<PIPManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>()); api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>()); api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
+22
View File
@@ -10,6 +10,7 @@ import {
createInternalCallbackAction, createInternalCallbackAction,
createLogAction, createLogAction,
createMediaPlayerAction, createMediaPlayerAction,
createNotificationAction,
createPerformAction, createPerformAction,
createPTZAction, createPTZAction,
createPTZControlsAction, 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', () => { describe('getActionConfigGivenAction', () => {
const action = createViewAction('clips'); 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();
});
});