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