Refactor menu code and add tests.
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ import pkg from '../package.json';
|
|||||||
import { actionHandler } from './action-handler-directive.js';
|
import { actionHandler } from './action-handler-directive.js';
|
||||||
import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.js';
|
import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.js';
|
||||||
import { CardController } from './card-controller/controller';
|
import { CardController } from './card-controller/controller';
|
||||||
import { MenuButtonController } from './components-lib/menu-controller';
|
import { MenuButtonController } from './components-lib/menu-button-controller';
|
||||||
import './components/elements.js';
|
import './components/elements.js';
|
||||||
import { FrigateCardElements } from './components/elements.js';
|
import { FrigateCardElements } from './components/elements.js';
|
||||||
import './components/menu.js';
|
import './components/menu.js';
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
|
import { StyleInfo } from 'lit/directives/style-map';
|
||||||
|
import { CameraManager } from '../camera-manager/manager';
|
||||||
|
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||||
|
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||||
|
import {
|
||||||
|
FrigateCardConfig,
|
||||||
|
FrigateCardCustomAction,
|
||||||
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
|
MenuItem,
|
||||||
|
} from '../config/types';
|
||||||
|
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||||
|
import { localize } from '../localize/localize.js';
|
||||||
|
import { MediaLoadedInfo } from '../types';
|
||||||
|
import {
|
||||||
|
createFrigateCardCameraAction,
|
||||||
|
createFrigateCardDisplayModeAction,
|
||||||
|
createFrigateCardMediaPlayerAction,
|
||||||
|
createFrigateCardShowPTZAction,
|
||||||
|
createFrigateCardSimpleAction,
|
||||||
|
} from '../utils/action';
|
||||||
|
import { getEntityIcon, getEntityTitle } from '../utils/ha';
|
||||||
|
import { hasUsablePTZ } from '../utils/ptz';
|
||||||
|
import { hasSubstream } from '../utils/substream';
|
||||||
|
import { View } from '../view/view';
|
||||||
|
|
||||||
|
export interface MenuButtonControllerOptions {
|
||||||
|
currentMediaLoadedInfo?: MediaLoadedInfo | null;
|
||||||
|
showCameraUIButton?: boolean;
|
||||||
|
inFullscreenMode?: boolean;
|
||||||
|
inExpandedMode?: boolean;
|
||||||
|
microphoneManager?: MicrophoneManager | null;
|
||||||
|
mediaPlayerController?: MediaPlayerManager | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MenuButtonController {
|
||||||
|
// Array of dynamic menu buttons to be added to menu.
|
||||||
|
protected _dynamicMenuButtons: MenuItem[] = [];
|
||||||
|
|
||||||
|
public addDynamicMenuButton(button: MenuItem): void {
|
||||||
|
if (!this._dynamicMenuButtons.includes(button)) {
|
||||||
|
this._dynamicMenuButtons.push(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeDynamicMenuButton(button: MenuItem): void {
|
||||||
|
this._dynamicMenuButtons = this._dynamicMenuButtons.filter(
|
||||||
|
(existingButton) => existingButton != button,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the menu buttons to display.
|
||||||
|
* @returns An array of menu buttons.
|
||||||
|
*/
|
||||||
|
public calculateButtons(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: FrigateCardConfig,
|
||||||
|
cameraManager: CameraManager,
|
||||||
|
view: View,
|
||||||
|
options?: MenuButtonControllerOptions,
|
||||||
|
): MenuItem[] {
|
||||||
|
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
||||||
|
const selectedCameraID = view.camera;
|
||||||
|
const selectedCameraConfig = cameraManager
|
||||||
|
.getStore()
|
||||||
|
.getCameraConfig(selectedCameraID);
|
||||||
|
const allSelectedCameraIDs = cameraManager
|
||||||
|
.getStore()
|
||||||
|
.getAllDependentCameras(selectedCameraID);
|
||||||
|
const selectedMedia = view.queryResults?.getSelectedResult();
|
||||||
|
|
||||||
|
const selectedCameraCapabilities =
|
||||||
|
cameraManager.getCameraCapabilities(selectedCameraID);
|
||||||
|
const aggregateCapabilities =
|
||||||
|
cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs);
|
||||||
|
const mediaCapabilities = selectedMedia
|
||||||
|
? cameraManager?.getMediaCapabilities(selectedMedia)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const buttons: MenuItem[] = [];
|
||||||
|
buttons.push({
|
||||||
|
// Use a magic icon value that the menu will use to render the custom
|
||||||
|
// Frigate icon.
|
||||||
|
icon: FRIGATE_BUTTON_MENU_ICON,
|
||||||
|
...config.menu.buttons.frigate,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.frigate'),
|
||||||
|
tap_action:
|
||||||
|
config.menu?.style === 'hidden'
|
||||||
|
? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction)
|
||||||
|
: (createFrigateCardSimpleAction('default') as FrigateCardCustomAction),
|
||||||
|
hold_action: createFrigateCardSimpleAction(
|
||||||
|
'diagnostics',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visibleCameraIDs.size) {
|
||||||
|
const menuItems = Array.from(
|
||||||
|
cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
|
||||||
|
([cameraID, config]) => {
|
||||||
|
const action = createFrigateCardCameraAction('camera_select', cameraID);
|
||||||
|
const metadata = cameraManager.getCameraMetadata(cameraID);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
icon: metadata?.icon,
|
||||||
|
entity: config.camera_entity,
|
||||||
|
state_color: true,
|
||||||
|
title: metadata?.title,
|
||||||
|
selected: selectedCameraID === cameraID,
|
||||||
|
...(action && { tap_action: action }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:video-switch',
|
||||||
|
...config.menu.buttons.cameras,
|
||||||
|
type: 'custom:frigate-card-menu-submenu',
|
||||||
|
title: localize('config.menu.buttons.cameras'),
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
|
||||||
|
const dependencies = [...allSelectedCameraIDs];
|
||||||
|
const override = view.context?.live?.overrides?.get(selectedCameraID);
|
||||||
|
|
||||||
|
if (dependencies.length === 2) {
|
||||||
|
// If there are only two dependencies (the main camera, and 1 other)
|
||||||
|
// then use a button not a menu to toggle.
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:video-input-component',
|
||||||
|
style:
|
||||||
|
override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
|
||||||
|
title: localize('config.menu.buttons.substreams'),
|
||||||
|
...config.menu.buttons.substreams,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
} else if (dependencies.length > 2) {
|
||||||
|
const menuItems = Array.from(dependencies, (cameraID) => {
|
||||||
|
const action = createFrigateCardCameraAction(
|
||||||
|
'live_substream_select',
|
||||||
|
cameraID,
|
||||||
|
);
|
||||||
|
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
|
||||||
|
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
icon: metadata?.icon,
|
||||||
|
entity: cameraConfig?.camera_entity,
|
||||||
|
state_color: true,
|
||||||
|
title: metadata?.title,
|
||||||
|
selected:
|
||||||
|
(view.context?.live?.overrides?.get(selectedCameraID) ??
|
||||||
|
selectedCameraID) === cameraID,
|
||||||
|
...(action && { tap_action: action }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:video-input-component',
|
||||||
|
title: localize('config.menu.buttons.substreams'),
|
||||||
|
style:
|
||||||
|
override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
|
||||||
|
...config.menu.buttons.substreams,
|
||||||
|
type: 'custom:frigate-card-menu-submenu',
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:cctv',
|
||||||
|
...config.menu.buttons.live,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.view.views.live'),
|
||||||
|
style: view.is('live') ? this._getEmphasizedStyle() : {},
|
||||||
|
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aggregateCapabilities?.supportsClips) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:filmstrip',
|
||||||
|
...config.menu.buttons.clips,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.view.views.clips'),
|
||||||
|
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
||||||
|
tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction,
|
||||||
|
hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregateCapabilities?.supportsSnapshots) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:camera',
|
||||||
|
...config.menu.buttons.snapshots,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.view.views.snapshots'),
|
||||||
|
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
'snapshots',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
hold_action: createFrigateCardSimpleAction(
|
||||||
|
'snapshot',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregateCapabilities?.supportsRecordings) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:album',
|
||||||
|
...config.menu.buttons.recordings,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.view.views.recordings'),
|
||||||
|
style: view.is('recordings') ? this._getEmphasizedStyle() : {},
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
'recordings',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
hold_action: createFrigateCardSimpleAction(
|
||||||
|
'recording',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:image',
|
||||||
|
...config.menu.buttons.image,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.view.views.image'),
|
||||||
|
style: view?.is('image') ? this._getEmphasizedStyle() : {},
|
||||||
|
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't show the timeline button unless there's at least one non-birdseye
|
||||||
|
// camera with a Frigate camera name.
|
||||||
|
if (aggregateCapabilities?.supportsTimeline) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:chart-gantt',
|
||||||
|
...config.menu.buttons.timeline,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.view.views.timeline'),
|
||||||
|
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
|
||||||
|
tap_action: createFrigateCardSimpleAction('timeline') as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaCapabilities?.canDownload && !this._isBeingCasted()) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:download',
|
||||||
|
...config.menu.buttons.download,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.download'),
|
||||||
|
tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.showCameraUIButton) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:web',
|
||||||
|
...config.menu.buttons.camera_ui,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.camera_ui'),
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
'camera_ui',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options?.microphoneManager &&
|
||||||
|
options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio
|
||||||
|
) {
|
||||||
|
const forbidden = options.microphoneManager.isForbidden();
|
||||||
|
const muted = options.microphoneManager.isMuted();
|
||||||
|
const buttonType = config.menu.buttons.microphone.type;
|
||||||
|
buttons.push({
|
||||||
|
icon: forbidden
|
||||||
|
? 'mdi:microphone-message-off'
|
||||||
|
: muted
|
||||||
|
? 'mdi:microphone-off'
|
||||||
|
: 'mdi:microphone',
|
||||||
|
...config.menu.buttons.microphone,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.microphone'),
|
||||||
|
style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
|
||||||
|
...(!forbidden &&
|
||||||
|
buttonType === 'momentary' && {
|
||||||
|
start_tap_action: createFrigateCardSimpleAction(
|
||||||
|
'microphone_unmute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
end_tap_action: createFrigateCardSimpleAction(
|
||||||
|
'microphone_mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
}),
|
||||||
|
...(!forbidden &&
|
||||||
|
buttonType === 'toggle' && {
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
options.microphoneManager.isMuted()
|
||||||
|
? 'microphone_unmute'
|
||||||
|
: 'microphone_mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._isBeingCasted()) {
|
||||||
|
buttons.push({
|
||||||
|
icon: options?.inFullscreenMode ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
|
||||||
|
...config.menu.buttons.fullscreen,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.fullscreen'),
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
'fullscreen',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
icon: options?.inExpandedMode ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all',
|
||||||
|
...config.menu.buttons.expand,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.expand'),
|
||||||
|
tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
|
||||||
|
style: options?.inExpandedMode ? this._getEmphasizedStyle() : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
options?.mediaPlayerController?.hasMediaPlayers() &&
|
||||||
|
(view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
|
||||||
|
) {
|
||||||
|
const mediaPlayerItems = options.mediaPlayerController
|
||||||
|
.getMediaPlayers()
|
||||||
|
.map((playerEntityID) => {
|
||||||
|
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
|
||||||
|
const state = hass.states[playerEntityID];
|
||||||
|
const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
|
||||||
|
const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
|
||||||
|
const disabled = !state || state.state === 'unavailable';
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
selected: false,
|
||||||
|
icon: getEntityIcon(hass, playerEntityID),
|
||||||
|
entity: playerEntityID,
|
||||||
|
state_color: false,
|
||||||
|
title: title,
|
||||||
|
disabled: disabled,
|
||||||
|
...(!disabled && playAction && { tap_action: playAction }),
|
||||||
|
...(!disabled && stopAction && { hold_action: stopAction }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:cast',
|
||||||
|
...config.menu.buttons.media_player,
|
||||||
|
type: 'custom:frigate-card-menu-submenu',
|
||||||
|
title: localize('config.menu.buttons.media_player'),
|
||||||
|
items: mediaPlayerItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
|
||||||
|
if (options.currentMediaLoadedInfo.capabilities?.supportsPause) {
|
||||||
|
const paused = options.currentMediaLoadedInfo.player.isPaused();
|
||||||
|
buttons.push({
|
||||||
|
icon: paused ? 'mdi:play' : 'mdi:pause',
|
||||||
|
...config.menu.buttons.play,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.play'),
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
paused ? 'play' : 'pause',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.currentMediaLoadedInfo.capabilities?.hasAudio) {
|
||||||
|
const muted = options.currentMediaLoadedInfo.player.isMuted();
|
||||||
|
buttons.push({
|
||||||
|
icon: muted ? 'mdi:volume-off' : 'mdi:volume-high',
|
||||||
|
...config.menu.buttons.mute,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.mute'),
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
muted ? 'unmute' : 'mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:monitor-screenshot',
|
||||||
|
...config.menu.buttons.screenshot,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.screenshot'),
|
||||||
|
tap_action: createFrigateCardSimpleAction(
|
||||||
|
'screenshot',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
|
||||||
|
const isGrid = view.isGrid();
|
||||||
|
buttons.push({
|
||||||
|
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
||||||
|
...config.menu.buttons.display_mode,
|
||||||
|
style: isGrid ? this._getEmphasizedStyle() : {},
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: isGrid
|
||||||
|
? localize('display_modes.single')
|
||||||
|
: localize('display_modes.grid'),
|
||||||
|
tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) {
|
||||||
|
const isOn =
|
||||||
|
view.context?.live?.ptzVisible === false
|
||||||
|
? false
|
||||||
|
: config.live.controls.ptz.mode === 'on';
|
||||||
|
buttons.push({
|
||||||
|
icon: 'mdi:pan',
|
||||||
|
...config.menu.buttons.ptz,
|
||||||
|
style: isOn ? this._getEmphasizedStyle() : {},
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.ptz'),
|
||||||
|
tap_action: createFrigateCardShowPTZAction(!isOn),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
||||||
|
style: this._getStyleFromActions(config, view, button, options),
|
||||||
|
...button,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return buttons.concat(styledDynamicButtons);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the style of emphasized menu items.
|
||||||
|
* @returns A StyleInfo.
|
||||||
|
*/
|
||||||
|
protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
|
||||||
|
if (critical) {
|
||||||
|
return {
|
||||||
|
animation: 'pulse 3s infinite',
|
||||||
|
color: 'var(--error-color, white)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
color: 'var(--primary-color, white)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a button determine if the style should be emphasized by examining all
|
||||||
|
* of the actions sequentially.
|
||||||
|
* @param button The button to examine.
|
||||||
|
* @returns A StyleInfo object.
|
||||||
|
*/
|
||||||
|
protected _getStyleFromActions(
|
||||||
|
config: FrigateCardConfig,
|
||||||
|
view: View,
|
||||||
|
button: MenuItem,
|
||||||
|
options?: MenuButtonControllerOptions,
|
||||||
|
): StyleInfo {
|
||||||
|
for (const actionSet of [
|
||||||
|
button.tap_action,
|
||||||
|
button.double_tap_action,
|
||||||
|
button.hold_action,
|
||||||
|
button.start_tap_action,
|
||||||
|
button.end_tap_action,
|
||||||
|
]) {
|
||||||
|
const actions = Array.isArray(actionSet) ? actionSet : [actionSet];
|
||||||
|
for (const action of actions) {
|
||||||
|
// All frigate card actions will have action of 'fire-dom-event' and
|
||||||
|
// styling only applies to those.
|
||||||
|
if (
|
||||||
|
!action ||
|
||||||
|
action.action !== 'fire-dom-event' ||
|
||||||
|
!('frigate_card_action' in action)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const frigateCardAction = action as FrigateCardCustomAction;
|
||||||
|
if (
|
||||||
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED.some(
|
||||||
|
(viewName) =>
|
||||||
|
viewName === frigateCardAction.frigate_card_action &&
|
||||||
|
view?.is(frigateCardAction.frigate_card_action),
|
||||||
|
) ||
|
||||||
|
(frigateCardAction.frigate_card_action === 'default' &&
|
||||||
|
view.is(config.view.default)) ||
|
||||||
|
(frigateCardAction.frigate_card_action === 'fullscreen' &&
|
||||||
|
!!options?.inFullscreenMode) ||
|
||||||
|
(frigateCardAction.frigate_card_action === 'camera_select' &&
|
||||||
|
view.camera === frigateCardAction.camera)
|
||||||
|
) {
|
||||||
|
return this._getEmphasizedStyle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the card is currently being casted.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
protected _isBeingCasted(): boolean {
|
||||||
|
return !!navigator.userAgent.match(/CrKey\//);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,519 +1,215 @@
|
|||||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import { StyleInfo } from 'lit/directives/style-map';
|
import { LitElement } from 'lit';
|
||||||
import { CameraManager } from '../camera-manager/manager';
|
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
|
||||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
import type {
|
||||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
ActionType,
|
||||||
import {
|
ActionsConfig,
|
||||||
FrigateCardConfig,
|
MenuConfig,
|
||||||
FrigateCardCustomAction,
|
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from '../config/types';
|
} from '../config/types.js';
|
||||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { StateParameters } from '../types.js';
|
||||||
import { MediaLoadedInfo } from '../types';
|
|
||||||
import {
|
import {
|
||||||
createFrigateCardCameraAction,
|
convertActionToFrigateCardCustomAction,
|
||||||
createFrigateCardDisplayModeAction,
|
frigateCardHandleActionConfig,
|
||||||
createFrigateCardMediaPlayerAction,
|
getActionConfigGivenAction,
|
||||||
createFrigateCardShowPTZAction,
|
|
||||||
createFrigateCardSimpleAction,
|
|
||||||
} from '../utils/action';
|
} from '../utils/action';
|
||||||
import { getEntityIcon, getEntityTitle } from '../utils/ha';
|
import { arrayify, isTruthy } from '../utils/basic.js';
|
||||||
import { hasUsablePTZ } from '../utils/ptz';
|
import { refreshDynamicStateParameters } from '../utils/ha/index.js';
|
||||||
import { hasSubstream } from '../utils/substream';
|
|
||||||
import { View } from '../view/view';
|
|
||||||
|
|
||||||
export interface MenuButtonControllerOptions {
|
export class MenuController {
|
||||||
currentMediaLoadedInfo?: MediaLoadedInfo | null;
|
protected _host: LitElement;
|
||||||
showCameraUIButton?: boolean;
|
protected _config: MenuConfig | null = null;
|
||||||
inFullscreenMode?: boolean;
|
protected _buttons: MenuItem[] = [];
|
||||||
inExpandedMode?: boolean;
|
protected _expanded = false;
|
||||||
microphoneManager?: MicrophoneManager | null;
|
|
||||||
mediaPlayerController?: MediaPlayerManager | null;
|
constructor(host: LitElement) {
|
||||||
|
this._host = host;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MenuButtonController {
|
public setMenuConfig(config: MenuConfig): void {
|
||||||
// Array of dynamic menu buttons to be added to menu.
|
this._config = config;
|
||||||
protected _dynamicMenuButtons: MenuItem[] = [];
|
this._host.style.setProperty(
|
||||||
|
'--frigate-card-menu-button-size',
|
||||||
|
`${config.button_size}px`,
|
||||||
|
);
|
||||||
|
|
||||||
public addDynamicMenuButton(button: MenuItem): void {
|
// Store the menu style, position and alignment as attributes (used for
|
||||||
if (!this._dynamicMenuButtons.includes(button)) {
|
// styling).
|
||||||
this._dynamicMenuButtons.push(button);
|
this._host.setAttribute('data-style', config.style);
|
||||||
}
|
this._host.setAttribute('data-position', config.position);
|
||||||
|
this._host.setAttribute('data-alignment', config.alignment);
|
||||||
|
|
||||||
|
this._sortButtons();
|
||||||
|
this._host.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeDynamicMenuButton(button: MenuItem): void {
|
public getMenuConfig(): MenuConfig | null {
|
||||||
this._dynamicMenuButtons = this._dynamicMenuButtons.filter(
|
return this._config;
|
||||||
(existingButton) => existingButton != button,
|
}
|
||||||
|
|
||||||
|
public isExpanded(): boolean {
|
||||||
|
return this._expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setButtons(buttons: MenuItem[]): void {
|
||||||
|
this._buttons = buttons;
|
||||||
|
this._sortButtons();
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getButtons(alignment: 'matching' | 'opposing'): MenuItem[] {
|
||||||
|
const style = this._config?.style;
|
||||||
|
|
||||||
|
const aligned = (button: MenuItem): boolean => {
|
||||||
|
return (
|
||||||
|
button.alignment === alignment || (alignment === 'matching' && !button.alignment)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enabled = (button: MenuItem): boolean => {
|
||||||
|
return button.enabled !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const suitableToShowIfHiddenMenu = (button: MenuItem): boolean => {
|
||||||
|
// If the hidden menu isn't expanded, only show the Frigate button.
|
||||||
|
return (
|
||||||
|
style !== 'hidden' || this._expanded || button.icon === FRIGATE_BUTTON_MENU_ICON
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this._buttons.filter(
|
||||||
|
(button) =>
|
||||||
|
enabled(button) && aligned(button) && suitableToShowIfHiddenMenu(button),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public setExpanded(expanded: boolean): void {
|
||||||
* Get the menu buttons to display.
|
this._expanded = expanded;
|
||||||
* @returns An array of menu buttons.
|
this._host.setAttribute('expanded', '');
|
||||||
*/
|
this._host.requestUpdate();
|
||||||
public calculateButtons(
|
}
|
||||||
|
|
||||||
|
public toggleExpanded(): void {
|
||||||
|
this.setExpanded(!this._expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public actionHandler(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config: FrigateCardConfig,
|
ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
|
||||||
cameraManager: CameraManager,
|
config?: ActionsConfig,
|
||||||
view: View,
|
): void {
|
||||||
options?: MenuButtonControllerOptions,
|
// These interactions should only be handled by the menu, as nothing
|
||||||
): MenuItem[] {
|
// upstream has the user-provided configuration.
|
||||||
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
ev.stopPropagation();
|
||||||
const selectedCameraID = view.camera;
|
|
||||||
const selectedCameraConfig = cameraManager
|
|
||||||
.getStore()
|
|
||||||
.getCameraConfig(selectedCameraID);
|
|
||||||
const allSelectedCameraIDs = cameraManager
|
|
||||||
.getStore()
|
|
||||||
.getAllDependentCameras(selectedCameraID);
|
|
||||||
const selectedMedia = view.queryResults?.getSelectedResult();
|
|
||||||
|
|
||||||
const selectedCameraCapabilities =
|
// If the event itself contains a configuration then use that. This is
|
||||||
cameraManager.getCameraCapabilities(selectedCameraID);
|
// useful in cases where the registration of the event handler does not have
|
||||||
const aggregateCapabilities =
|
// access to the actual desired configuration (e.g. action events generated
|
||||||
cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs);
|
// by a submenu).
|
||||||
const mediaCapabilities = selectedMedia
|
if (ev.detail.config) {
|
||||||
? cameraManager?.getMediaCapabilities(selectedMedia)
|
config = ev.detail.config;
|
||||||
: null;
|
}
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const buttons: MenuItem[] = [];
|
const interaction: string = ev.detail.action;
|
||||||
buttons.push({
|
const action = getActionConfigGivenAction(interaction, config);
|
||||||
// Use a magic icon value that the menu will use to render the custom
|
if (!action) {
|
||||||
// Frigate icon.
|
return;
|
||||||
icon: FRIGATE_BUTTON_MENU_ICON,
|
}
|
||||||
...config.menu.buttons.frigate,
|
const actions = arrayify(action);
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.frigate'),
|
|
||||||
tap_action:
|
|
||||||
config.menu?.style === 'hidden'
|
|
||||||
? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction)
|
|
||||||
: (createFrigateCardSimpleAction('default') as FrigateCardCustomAction),
|
|
||||||
hold_action: createFrigateCardSimpleAction(
|
|
||||||
'diagnostics',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (visibleCameraIDs.size) {
|
// A note on the complexity below: By default the menu should close when a
|
||||||
const menuItems = Array.from(
|
// user takes an action, an exception is if the user is specifically
|
||||||
cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
|
// manipulating the menu in the actions themselves.
|
||||||
([cameraID, config]) => {
|
let menuToggle = false;
|
||||||
const action = createFrigateCardCameraAction('camera_select', cameraID);
|
|
||||||
const metadata = cameraManager.getCameraMetadata(cameraID);
|
|
||||||
|
|
||||||
return {
|
const toggleLessActions = actions.filter(
|
||||||
enabled: true,
|
(item) => isTruthy(item) && !this._isMenuToggleAction(item),
|
||||||
icon: metadata?.icon,
|
|
||||||
entity: config.camera_entity,
|
|
||||||
state_color: true,
|
|
||||||
title: metadata?.title,
|
|
||||||
selected: selectedCameraID === cameraID,
|
|
||||||
...(action && { tap_action: action }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
if (toggleLessActions.length != actions.length) {
|
||||||
buttons.push({
|
menuToggle = true;
|
||||||
icon: 'mdi:video-switch',
|
|
||||||
...config.menu.buttons.cameras,
|
|
||||||
type: 'custom:frigate-card-menu-submenu',
|
|
||||||
title: localize('config.menu.buttons.cameras'),
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
|
if (toggleLessActions.length) {
|
||||||
const dependencies = [...allSelectedCameraIDs];
|
frigateCardHandleActionConfig(this._host, hass, config, interaction, actions);
|
||||||
const override = view.context?.live?.overrides?.get(selectedCameraID);
|
|
||||||
|
|
||||||
if (dependencies.length === 2) {
|
|
||||||
// If there are only two dependencies (the main camera, and 1 other)
|
|
||||||
// then use a button not a menu to toggle.
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:video-input-component',
|
|
||||||
style:
|
|
||||||
override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
|
|
||||||
title: localize('config.menu.buttons.substreams'),
|
|
||||||
...config.menu.buttons.substreams,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
} else if (dependencies.length > 2) {
|
|
||||||
const menuItems = Array.from(dependencies, (cameraID) => {
|
|
||||||
const action = createFrigateCardCameraAction(
|
|
||||||
'live_substream_select',
|
|
||||||
cameraID,
|
|
||||||
);
|
|
||||||
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
|
|
||||||
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
icon: metadata?.icon,
|
|
||||||
entity: cameraConfig?.camera_entity,
|
|
||||||
state_color: true,
|
|
||||||
title: metadata?.title,
|
|
||||||
selected:
|
|
||||||
(view.context?.live?.overrides?.get(selectedCameraID) ??
|
|
||||||
selectedCameraID) === cameraID,
|
|
||||||
...(action && { tap_action: action }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:video-input-component',
|
|
||||||
title: localize('config.menu.buttons.substreams'),
|
|
||||||
style:
|
|
||||||
override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
|
|
||||||
...config.menu.buttons.substreams,
|
|
||||||
type: 'custom:frigate-card-menu-submenu',
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.push({
|
if (this._isHidingMenu()) {
|
||||||
icon: 'mdi:cctv',
|
if (menuToggle) {
|
||||||
...config.menu.buttons.live,
|
this.setExpanded(!this._expanded);
|
||||||
type: 'custom:frigate-card-menu-icon',
|
} else {
|
||||||
title: localize('config.view.views.live'),
|
// Don't close the menu if there is another action to come.
|
||||||
style: view.is('live') ? this._getEmphasizedStyle() : {},
|
const holdAction = getActionConfigGivenAction('hold', config);
|
||||||
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
|
const doubleTapAction = getActionConfigGivenAction('double_tap', config);
|
||||||
});
|
const tapAction = getActionConfigGivenAction('tap', config);
|
||||||
|
const endTapAction = getActionConfigGivenAction('end_tap', config);
|
||||||
if (aggregateCapabilities?.supportsClips) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:filmstrip',
|
|
||||||
...config.menu.buttons.clips,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.view.views.clips'),
|
|
||||||
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
|
||||||
tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction,
|
|
||||||
hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aggregateCapabilities?.supportsSnapshots) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:camera',
|
|
||||||
...config.menu.buttons.snapshots,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.view.views.snapshots'),
|
|
||||||
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
'snapshots',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
hold_action: createFrigateCardSimpleAction(
|
|
||||||
'snapshot',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aggregateCapabilities?.supportsRecordings) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:album',
|
|
||||||
...config.menu.buttons.recordings,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.view.views.recordings'),
|
|
||||||
style: view.is('recordings') ? this._getEmphasizedStyle() : {},
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
'recordings',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
hold_action: createFrigateCardSimpleAction(
|
|
||||||
'recording',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:image',
|
|
||||||
...config.menu.buttons.image,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.view.views.image'),
|
|
||||||
style: view?.is('image') ? this._getEmphasizedStyle() : {},
|
|
||||||
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Don't show the timeline button unless there's at least one non-birdseye
|
|
||||||
// camera with a Frigate camera name.
|
|
||||||
if (aggregateCapabilities?.supportsTimeline) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:chart-gantt',
|
|
||||||
...config.menu.buttons.timeline,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.view.views.timeline'),
|
|
||||||
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
|
|
||||||
tap_action: createFrigateCardSimpleAction('timeline') as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaCapabilities?.canDownload && !this._isBeingCasted()) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:download',
|
|
||||||
...config.menu.buttons.download,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.download'),
|
|
||||||
tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.showCameraUIButton) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:web',
|
|
||||||
...config.menu.buttons.camera_ui,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.camera_ui'),
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
'camera_ui',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
options?.microphoneManager &&
|
interaction === 'end_tap' ||
|
||||||
options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio
|
(interaction === 'start_tap' &&
|
||||||
|
!holdAction &&
|
||||||
|
!doubleTapAction &&
|
||||||
|
!tapAction &&
|
||||||
|
!endTapAction) ||
|
||||||
|
(interaction !== 'end_tap' && !endTapAction)
|
||||||
) {
|
) {
|
||||||
const forbidden = options.microphoneManager.isForbidden();
|
this.setExpanded(false);
|
||||||
const muted = options.microphoneManager.isMuted();
|
}
|
||||||
const buttonType = config.menu.buttons.microphone.type;
|
}
|
||||||
buttons.push({
|
}
|
||||||
icon: forbidden
|
|
||||||
? 'mdi:microphone-message-off'
|
|
||||||
: muted
|
|
||||||
? 'mdi:microphone-off'
|
|
||||||
: 'mdi:microphone',
|
|
||||||
...config.menu.buttons.microphone,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.microphone'),
|
|
||||||
style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
|
|
||||||
...(!forbidden &&
|
|
||||||
buttonType === 'momentary' && {
|
|
||||||
start_tap_action: createFrigateCardSimpleAction(
|
|
||||||
'microphone_unmute',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
end_tap_action: createFrigateCardSimpleAction(
|
|
||||||
'microphone_mute',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
}),
|
|
||||||
...(!forbidden &&
|
|
||||||
buttonType === 'toggle' && {
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
options.microphoneManager.isMuted()
|
|
||||||
? 'microphone_unmute'
|
|
||||||
: 'microphone_mute',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._isBeingCasted()) {
|
public getFreshButtonState(hass: HomeAssistant, button: MenuItem): StateParameters {
|
||||||
buttons.push({
|
const stateParameters = { ...button };
|
||||||
icon: options?.inFullscreenMode ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
|
return hass && button.type === 'custom:frigate-card-menu-state-icon'
|
||||||
...config.menu.buttons.fullscreen,
|
? refreshDynamicStateParameters(hass, stateParameters)
|
||||||
type: 'custom:frigate-card-menu-icon',
|
: stateParameters;
|
||||||
title: localize('config.menu.buttons.fullscreen'),
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
'fullscreen',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.push({
|
public getSVGPath(button: MenuItem): string {
|
||||||
icon: options?.inExpandedMode ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all',
|
return button.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
|
||||||
...config.menu.buttons.expand,
|
}
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.expand'),
|
|
||||||
tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
|
|
||||||
style: options?.inExpandedMode ? this._getEmphasizedStyle() : {},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
protected _sortButtons(): void {
|
||||||
|
const style = this._config?.style;
|
||||||
|
const sortButtons = (a: MenuItem, b: MenuItem): number => {
|
||||||
|
// If the menu is hidden, the Frigate button must come first.
|
||||||
|
if (style === 'hidden') {
|
||||||
|
if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
|
||||||
|
return -1;
|
||||||
|
} else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise sort by priority.
|
||||||
if (
|
if (
|
||||||
options?.mediaPlayerController?.hasMediaPlayers() &&
|
a.priority === undefined ||
|
||||||
(view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
|
(b.priority !== undefined && b.priority > a.priority)
|
||||||
) {
|
) {
|
||||||
const mediaPlayerItems = options.mediaPlayerController
|
return 1;
|
||||||
.getMediaPlayers()
|
|
||||||
.map((playerEntityID) => {
|
|
||||||
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
|
|
||||||
const state = hass.states[playerEntityID];
|
|
||||||
const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
|
|
||||||
const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
|
|
||||||
const disabled = !state || state.state === 'unavailable';
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
selected: false,
|
|
||||||
icon: getEntityIcon(hass, playerEntityID),
|
|
||||||
entity: playerEntityID,
|
|
||||||
state_color: false,
|
|
||||||
title: title,
|
|
||||||
disabled: disabled,
|
|
||||||
...(!disabled && playAction && { tap_action: playAction }),
|
|
||||||
...(!disabled && stopAction && { hold_action: stopAction }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:cast',
|
|
||||||
...config.menu.buttons.media_player,
|
|
||||||
type: 'custom:frigate-card-menu-submenu',
|
|
||||||
title: localize('config.menu.buttons.media_player'),
|
|
||||||
items: mediaPlayerItems,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
|
|
||||||
if (options.currentMediaLoadedInfo.capabilities?.supportsPause) {
|
|
||||||
const paused = options.currentMediaLoadedInfo.player.isPaused();
|
|
||||||
buttons.push({
|
|
||||||
icon: paused ? 'mdi:play' : 'mdi:pause',
|
|
||||||
...config.menu.buttons.play,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.play'),
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
paused ? 'play' : 'pause',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.currentMediaLoadedInfo.capabilities?.hasAudio) {
|
|
||||||
const muted = options.currentMediaLoadedInfo.player.isMuted();
|
|
||||||
buttons.push({
|
|
||||||
icon: muted ? 'mdi:volume-off' : 'mdi:volume-high',
|
|
||||||
...config.menu.buttons.mute,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.mute'),
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
muted ? 'unmute' : 'mute',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:monitor-screenshot',
|
|
||||||
...config.menu.buttons.screenshot,
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.screenshot'),
|
|
||||||
tap_action: createFrigateCardSimpleAction(
|
|
||||||
'screenshot',
|
|
||||||
) as FrigateCardCustomAction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
|
|
||||||
const isGrid = view.isGrid();
|
|
||||||
buttons.push({
|
|
||||||
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
|
||||||
...config.menu.buttons.display_mode,
|
|
||||||
style: isGrid ? this._getEmphasizedStyle() : {},
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: isGrid
|
|
||||||
? localize('display_modes.single')
|
|
||||||
: localize('display_modes.grid'),
|
|
||||||
tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) {
|
|
||||||
const isOn =
|
|
||||||
view.context?.live?.ptzVisible === false
|
|
||||||
? false
|
|
||||||
: config.live.controls.ptz.mode === 'on';
|
|
||||||
buttons.push({
|
|
||||||
icon: 'mdi:pan',
|
|
||||||
...config.menu.buttons.ptz,
|
|
||||||
style: isOn ? this._getEmphasizedStyle() : {},
|
|
||||||
type: 'custom:frigate-card-menu-icon',
|
|
||||||
title: localize('config.menu.buttons.ptz'),
|
|
||||||
tap_action: createFrigateCardShowPTZAction(!isOn),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
|
||||||
style: this._getStyleFromActions(config, view, button, options),
|
|
||||||
...button,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return buttons.concat(styledDynamicButtons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the style of emphasized menu items.
|
|
||||||
* @returns A StyleInfo.
|
|
||||||
*/
|
|
||||||
protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
|
|
||||||
if (critical) {
|
|
||||||
return {
|
|
||||||
animation: 'pulse 3s infinite',
|
|
||||||
color: 'var(--error-color, white)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
color: 'var(--primary-color, white)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a button determine if the style should be emphasized by examining all
|
|
||||||
* of the actions sequentially.
|
|
||||||
* @param button The button to examine.
|
|
||||||
* @returns A StyleInfo object.
|
|
||||||
*/
|
|
||||||
protected _getStyleFromActions(
|
|
||||||
config: FrigateCardConfig,
|
|
||||||
view: View,
|
|
||||||
button: MenuItem,
|
|
||||||
options?: MenuButtonControllerOptions,
|
|
||||||
): StyleInfo {
|
|
||||||
for (const actionSet of [
|
|
||||||
button.tap_action,
|
|
||||||
button.double_tap_action,
|
|
||||||
button.hold_action,
|
|
||||||
button.start_tap_action,
|
|
||||||
button.end_tap_action,
|
|
||||||
]) {
|
|
||||||
const actions = Array.isArray(actionSet) ? actionSet : [actionSet];
|
|
||||||
for (const action of actions) {
|
|
||||||
// All frigate card actions will have action of 'fire-dom-event' and
|
|
||||||
// styling only applies to those.
|
|
||||||
if (
|
if (
|
||||||
!action ||
|
b.priority === undefined ||
|
||||||
action.action !== 'fire-dom-event' ||
|
(a.priority !== undefined && b.priority < a.priority)
|
||||||
!('frigate_card_action' in action)
|
|
||||||
) {
|
) {
|
||||||
continue;
|
return -1;
|
||||||
}
|
}
|
||||||
const frigateCardAction = action as FrigateCardCustomAction;
|
return 0;
|
||||||
if (
|
};
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED.some(
|
|
||||||
(viewName) =>
|
this._buttons.sort(sortButtons);
|
||||||
viewName === frigateCardAction.frigate_card_action &&
|
|
||||||
view?.is(frigateCardAction.frigate_card_action),
|
|
||||||
) ||
|
|
||||||
(frigateCardAction.frigate_card_action === 'default' &&
|
|
||||||
view.is(config.view.default)) ||
|
|
||||||
(frigateCardAction.frigate_card_action === 'fullscreen' &&
|
|
||||||
!!options?.inFullscreenMode) ||
|
|
||||||
(frigateCardAction.frigate_card_action === 'camera_select' &&
|
|
||||||
view.camera === frigateCardAction.camera)
|
|
||||||
) {
|
|
||||||
return this._getEmphasizedStyle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _isHidingMenu(): boolean {
|
||||||
* Determine if the card is currently being casted.
|
return this._config?.style === 'hidden' ?? false;
|
||||||
* @returns
|
}
|
||||||
*/
|
|
||||||
protected _isBeingCasted(): boolean {
|
protected _isMenuToggleAction(action: ActionType): boolean {
|
||||||
return !!navigator.userAgent.match(/CrKey\//);
|
const frigateCardAction = convertActionToFrigateCardCustomAction(action);
|
||||||
|
return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-277
@@ -1,249 +1,52 @@
|
|||||||
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||||
import {
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
CSSResultGroup,
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
LitElement,
|
|
||||||
PropertyValues,
|
|
||||||
TemplateResult,
|
|
||||||
html,
|
|
||||||
unsafeCSS,
|
|
||||||
} from 'lit';
|
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { actionHandler } from '../action-handler-directive.js';
|
import { actionHandler } from '../action-handler-directive.js';
|
||||||
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
|
import { MenuController } from '../components-lib/menu-controller.js';
|
||||||
import type {
|
import type { MenuConfig, MenuItem } from '../config/types.js';
|
||||||
ActionType,
|
|
||||||
ActionsConfig,
|
|
||||||
MenuConfig,
|
|
||||||
MenuItem,
|
|
||||||
} from '../config/types.js';
|
|
||||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
|
|
||||||
import menuStyle from '../scss/menu.scss';
|
import menuStyle from '../scss/menu.scss';
|
||||||
import type { StateParameters } from '../types.js';
|
import { frigateCardHasAction } from '../utils/action.js';
|
||||||
import {
|
|
||||||
convertActionToFrigateCardCustomAction,
|
|
||||||
frigateCardHandleActionConfig,
|
|
||||||
frigateCardHasAction,
|
|
||||||
getActionConfigGivenAction,
|
|
||||||
} from '../utils/action.js';
|
|
||||||
import { refreshDynamicStateParameters } from '../utils/ha';
|
|
||||||
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
|
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
|
||||||
import './submenu.js';
|
import './submenu.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* A menu for the FrigateCard.
|
|
||||||
*/
|
|
||||||
@customElement('frigate-card-menu')
|
@customElement('frigate-card-menu')
|
||||||
export class FrigateCardMenu extends LitElement {
|
export class FrigateCardMenu extends LitElement {
|
||||||
@property({ attribute: false })
|
protected _controller = new MenuController(this);
|
||||||
public hass?: HomeAssistant;
|
|
||||||
|
|
||||||
@property({ attribute: true, type: Boolean, reflect: true })
|
|
||||||
public expanded = false;
|
|
||||||
|
|
||||||
set menuConfig(menuConfig: MenuConfig) {
|
|
||||||
this._menuConfig = menuConfig;
|
|
||||||
if (menuConfig) {
|
|
||||||
this.style.setProperty(
|
|
||||||
'--frigate-card-menu-button-size',
|
|
||||||
`${menuConfig.button_size}px`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Store the menu style, position and alignment as attributes (used for
|
|
||||||
// styling).
|
|
||||||
this.setAttribute('data-style', menuConfig.style);
|
|
||||||
this.setAttribute('data-position', menuConfig.position);
|
|
||||||
this.setAttribute('data-alignment', menuConfig.alignment);
|
|
||||||
}
|
|
||||||
@state()
|
|
||||||
protected _menuConfig?: MenuConfig;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public buttons: MenuItem[] = [];
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public entityRegistryManager?: EntityRegistryManager;
|
public entityRegistryManager?: EntityRegistryManager;
|
||||||
|
|
||||||
/**
|
@property({ attribute: false })
|
||||||
* Determine if a given menu configuration is a hiding menu.
|
public hass?: HomeAssistant;
|
||||||
* @param menuConfig The menu configuration.
|
|
||||||
* @returns `true` if the menu is hiding, `false` otherwise.
|
set menuConfig(menuConfig: MenuConfig) {
|
||||||
*/
|
this._controller.setMenuConfig(menuConfig);
|
||||||
static isHidingMenu(menuConfig: MenuConfig | undefined): boolean {
|
}
|
||||||
return menuConfig?.style === 'hidden' ?? false;
|
|
||||||
|
set buttons(buttons: MenuItem[]) {
|
||||||
|
this._controller.setButtons(buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
set expanded(expanded: boolean) {
|
||||||
|
this._controller.setExpanded(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle the menu. Has no action if menu is not hiding/expandable.
|
|
||||||
*/
|
|
||||||
public toggleMenu(): void {
|
public toggleMenu(): void {
|
||||||
if (this._isHidingMenu()) {
|
this._controller.toggleExpanded();
|
||||||
this.expanded = !this.expanded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a given menu configuration is a hiding menu (internal version).
|
|
||||||
* @returns `true` if the menu is hiding, `false` otherwise.
|
|
||||||
*/
|
|
||||||
protected _isHidingMenu(): boolean {
|
|
||||||
return FrigateCardMenu.isHidingMenu(this._menuConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a given action is intended to toggle the menu.
|
|
||||||
* @param action The action to check.
|
|
||||||
* @returns `true` if the action toggles the menu, `false` otherwise.
|
|
||||||
*/
|
|
||||||
protected _isMenuToggleAction(action: ActionType | null): boolean {
|
|
||||||
// Determine if this action is a Frigate card action, if so handle it
|
|
||||||
// internally.
|
|
||||||
if (!action) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const frigateCardAction = convertActionToFrigateCardCustomAction(action);
|
|
||||||
return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an action on a menu button.
|
|
||||||
* @param ev The action event.
|
|
||||||
* @param button The button configuration.
|
|
||||||
*/
|
|
||||||
protected _actionHandler(
|
|
||||||
ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
|
|
||||||
config?: ActionsConfig,
|
|
||||||
): void {
|
|
||||||
if (!ev) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the event itself contains a configuration then use that. This is
|
|
||||||
// useful in cases where the registration of the event handler does not have
|
|
||||||
// access to the actual desired configuration (e.g. action events generated
|
|
||||||
// by a submenu).
|
|
||||||
if (ev.detail.config) {
|
|
||||||
config = ev.detail.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// These interactions should only be handled by the menu, as nothing
|
|
||||||
// upstream has the user-provided configuration.
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
const interaction: string = ev.detail.action;
|
|
||||||
let action = getActionConfigGivenAction(interaction, config);
|
|
||||||
if (!config || !interaction) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tookAction = false;
|
|
||||||
let menuToggle = false;
|
|
||||||
|
|
||||||
if (Array.isArray(action)) {
|
|
||||||
// Case 1: An array of actions.
|
|
||||||
// Strip out actions that toggle the menu.
|
|
||||||
const actionCount = action.length;
|
|
||||||
action = action.filter((item) => !this._isMenuToggleAction(item));
|
|
||||||
if (action.length != actionCount) {
|
|
||||||
menuToggle = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are still actions left, handle them as usual.
|
|
||||||
if (action.length) {
|
|
||||||
tookAction = frigateCardHandleActionConfig(
|
|
||||||
this,
|
|
||||||
this.hass as HomeAssistant,
|
|
||||||
config,
|
|
||||||
interaction,
|
|
||||||
action,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Case 2: Either a specific action, or no action at all (i.e. default
|
|
||||||
// action for `tap`).
|
|
||||||
if (this._isMenuToggleAction(action)) {
|
|
||||||
menuToggle = true;
|
|
||||||
} else {
|
|
||||||
tookAction = frigateCardHandleActionConfig(
|
|
||||||
this,
|
|
||||||
this.hass as HomeAssistant,
|
|
||||||
config,
|
|
||||||
interaction,
|
|
||||||
action,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._isHidingMenu()) {
|
|
||||||
if (menuToggle) {
|
|
||||||
this.expanded = !this.expanded;
|
|
||||||
} else if (tookAction) {
|
|
||||||
// Don't close the menu if there is another action to come.
|
|
||||||
const holdAction = getActionConfigGivenAction('hold', config);
|
|
||||||
const doubleTapAction = getActionConfigGivenAction('double_tap', config);
|
|
||||||
const tapAction = getActionConfigGivenAction('tap', config);
|
|
||||||
const endTapAction = getActionConfigGivenAction('end_tap', config);
|
|
||||||
|
|
||||||
if (
|
|
||||||
interaction === 'end_tap' ||
|
|
||||||
(interaction === 'start_tap' &&
|
|
||||||
!holdAction &&
|
|
||||||
!doubleTapAction &&
|
|
||||||
!tapAction &&
|
|
||||||
!endTapAction) ||
|
|
||||||
(interaction !== 'end_tap' && !endTapAction)
|
|
||||||
) {
|
|
||||||
this.expanded = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure menu buttons are sorted before the render.
|
|
||||||
* @param changedProps The changed properties
|
|
||||||
*/
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
|
||||||
const style = this._menuConfig?.style;
|
|
||||||
const sortButtons = (a: MenuItem, b: MenuItem): number => {
|
|
||||||
// If the menu is hidden, the Frigate button must come first.
|
|
||||||
if (style === 'hidden') {
|
|
||||||
if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
|
|
||||||
return -1;
|
|
||||||
} else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise sort by priority.
|
|
||||||
if (
|
|
||||||
a.priority === undefined ||
|
|
||||||
(b.priority !== undefined && b.priority > a.priority)
|
|
||||||
) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
b.priority === undefined ||
|
|
||||||
(a.priority !== undefined && b.priority < a.priority)
|
|
||||||
) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (changedProps.has('_menuConfig') || changedProps.has('buttons')) {
|
|
||||||
this.buttons.sort(sortButtons);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _renderButton(button: MenuItem): TemplateResult | void {
|
protected _renderButton(button: MenuItem): TemplateResult | void {
|
||||||
|
if (!this.hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (button.type === 'custom:frigate-card-menu-submenu') {
|
if (button.type === 'custom:frigate-card-menu-submenu') {
|
||||||
return html` <frigate-card-submenu
|
return html` <frigate-card-submenu
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.submenu=${button}
|
.submenu=${button}
|
||||||
@action=${this._actionHandler.bind(this)}
|
@action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)}
|
||||||
>
|
>
|
||||||
</frigate-card-submenu>`;
|
</frigate-card-submenu>`;
|
||||||
} else if (button.type === 'custom:frigate-card-menu-submenu-select') {
|
} else if (button.type === 'custom:frigate-card-menu-submenu-select') {
|
||||||
@@ -251,26 +54,11 @@ export class FrigateCardMenu extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.submenuSelect=${button}
|
.submenuSelect=${button}
|
||||||
.entityRegistryManager=${this.entityRegistryManager}
|
.entityRegistryManager=${this.entityRegistryManager}
|
||||||
@action=${this._actionHandler.bind(this)}
|
@action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)}
|
||||||
>
|
>
|
||||||
</frigate-card-submenu-select>`;
|
</frigate-card-submenu-select>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stateParameters = { ...button } as StateParameters;
|
|
||||||
const svgPath =
|
|
||||||
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
|
|
||||||
|
|
||||||
if (this.hass && button.type === 'custom:frigate-card-menu-state-icon') {
|
|
||||||
stateParameters = refreshDynamicStateParameters(this.hass, stateParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHold = frigateCardHasAction(button.hold_action);
|
|
||||||
const hasDoubleClick = frigateCardHasAction(button.double_tap_action);
|
|
||||||
|
|
||||||
const classes = {
|
|
||||||
button: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// For `data-domain` and `data-state`, see: See
|
// For `data-domain` and `data-state`, see: See
|
||||||
// https://github.com/home-assistant/frontend/blob/dev/src/components/entity/state-badge.ts#L54
|
// https://github.com/home-assistant/frontend/blob/dev/src/components/entity/state-badge.ts#L54
|
||||||
@@ -283,62 +71,53 @@ export class FrigateCardMenu extends LitElement {
|
|||||||
// - Static styling based on domain (`data-domain`) and state
|
// - Static styling based on domain (`data-domain`) and state
|
||||||
// (`data-state`). This looks up a CSS style in `menu.scss`.
|
// (`data-state`). This looks up a CSS style in `menu.scss`.
|
||||||
|
|
||||||
|
const buttonState = this._controller.getFreshButtonState(this.hass, button);
|
||||||
|
const svgPath = this._controller.getSVGPath(button);
|
||||||
|
|
||||||
return html` <ha-icon-button
|
return html` <ha-icon-button
|
||||||
data-domain=${ifDefined(stateParameters.data_domain)}
|
data-domain=${ifDefined(buttonState.data_domain)}
|
||||||
data-state=${ifDefined(stateParameters.data_state)}
|
data-state=${ifDefined(buttonState.data_state)}
|
||||||
class="${classMap(classes)}"
|
class="button"
|
||||||
style="${styleMap(stateParameters.style || {})}"
|
style="${styleMap(buttonState.style || {})}"
|
||||||
.actionHandler=${actionHandler({
|
.actionHandler=${actionHandler({
|
||||||
hasHold: hasHold,
|
hasHold: frigateCardHasAction(button.hold_action),
|
||||||
hasDoubleClick: hasDoubleClick,
|
hasDoubleClick: frigateCardHasAction(button.double_tap_action),
|
||||||
})}
|
})}
|
||||||
.label=${stateParameters.title || ''}
|
.label=${buttonState.title || ''}
|
||||||
@action=${(ev) => this._actionHandler(ev, button)}
|
@action=${(ev) =>
|
||||||
|
this.hass && this._controller.actionHandler(this.hass, ev, button)}
|
||||||
>
|
>
|
||||||
${svgPath
|
${svgPath
|
||||||
? html`<ha-svg-icon .path="${svgPath}"></ha-svg-icon>`
|
? html`<ha-svg-icon .path="${svgPath}"></ha-svg-icon>`
|
||||||
: html`<ha-icon
|
: html`<ha-icon
|
||||||
icon="${stateParameters.icon || 'mdi:gesture-tap-button'}"
|
icon="${buttonState.icon || 'mdi:gesture-tap-button'}"
|
||||||
></ha-icon>`}
|
></ha-icon>`}
|
||||||
</ha-icon-button>`;
|
</ha-icon-button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this._menuConfig) {
|
const config = this._controller.getMenuConfig();
|
||||||
return;
|
const style = config?.style;
|
||||||
}
|
if (!config || style === 'none') {
|
||||||
const style = this._menuConfig.style;
|
|
||||||
if (style === 'none') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const matchingButtons = this._controller.getButtons('matching');
|
||||||
|
const opposingButtons = this._controller.getButtons('opposing');
|
||||||
|
|
||||||
// If the hidden menu isn't expanded, only show the Frigate button.
|
return html` <div
|
||||||
const matchingButtons = (
|
class="matching"
|
||||||
style !== 'hidden' || this.expanded
|
style="${styleMap({
|
||||||
? this.buttons.filter(
|
|
||||||
(button) => !button.alignment || button.alignment === 'matching',
|
|
||||||
)
|
|
||||||
: this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON)
|
|
||||||
).filter((button) => button.enabled !== false);
|
|
||||||
|
|
||||||
const opposingButtons =
|
|
||||||
style !== 'hidden' || this.expanded
|
|
||||||
? this.buttons.filter(
|
|
||||||
(button) => button.alignment === 'opposing' && button.enabled !== false,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const matchingStyle = {
|
|
||||||
flex: String(matchingButtons.length),
|
flex: String(matchingButtons.length),
|
||||||
};
|
})}"
|
||||||
const opposingStyle = {
|
>
|
||||||
flex: String(opposingButtons.length),
|
|
||||||
};
|
|
||||||
|
|
||||||
return html` <div class="matching" style="${styleMap(matchingStyle)}">
|
|
||||||
${matchingButtons.map((button) => this._renderButton(button))}
|
${matchingButtons.map((button) => this._renderButton(button))}
|
||||||
</div>
|
</div>
|
||||||
<div class="opposing" style="${styleMap(opposingStyle)}">
|
<div
|
||||||
|
class="opposing"
|
||||||
|
style="${styleMap({
|
||||||
|
flex: String(opposingButtons.length),
|
||||||
|
})}"
|
||||||
|
>
|
||||||
${opposingButtons.map((button) => this._renderButton(button))}
|
${opposingButtons.map((button) => this._renderButton(button))}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -326,7 +326,7 @@ const actionsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const elementsBaseSchema = actionsBaseSchema.extend({
|
const elementsBaseSchema = actionsBaseSchema.extend({
|
||||||
style: z.object({}).passthrough().optional(),
|
style: z.record(z.string().nullable()).optional(),
|
||||||
title: z.string().nullable().optional(),
|
title: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1255,7 +1255,7 @@ const hiddenButtonSchema = menuBaseSchema.extend({
|
|||||||
priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority),
|
priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority),
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuConfigSchema = z
|
export const menuConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style),
|
style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style),
|
||||||
position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position),
|
position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position),
|
||||||
|
|||||||
+5
-5
@@ -115,15 +115,15 @@ export function getActionConfigGivenAction(
|
|||||||
if (!interaction || !config) {
|
if (!interaction || !config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (interaction == 'tap' && config.tap_action) {
|
if (interaction === 'tap' && config.tap_action) {
|
||||||
return config.tap_action;
|
return config.tap_action;
|
||||||
} else if (interaction == 'hold' && config.hold_action) {
|
} else if (interaction === 'hold' && config.hold_action) {
|
||||||
return config.hold_action;
|
return config.hold_action;
|
||||||
} else if (interaction == 'double_tap' && config.double_tap_action) {
|
} else if (interaction === 'double_tap' && config.double_tap_action) {
|
||||||
return config.double_tap_action;
|
return config.double_tap_action;
|
||||||
} else if (interaction == 'end_tap' && config.end_tap_action) {
|
} else if (interaction === 'end_tap' && config.end_tap_action) {
|
||||||
return config.end_tap_action;
|
return config.end_tap_action;
|
||||||
} else if (interaction == 'start_tap' && config.start_tap_action) {
|
} else if (interaction === 'start_tap' && config.start_tap_action) {
|
||||||
return config.start_tap_action;
|
return config.start_tap_action;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -10,10 +10,10 @@ export default defineConfig({
|
|||||||
// Thresholds will automatically be updated as coverage improves to avoid
|
// Thresholds will automatically be updated as coverage improves to avoid
|
||||||
// back-sliding.
|
// back-sliding.
|
||||||
thresholdAutoUpdate: true,
|
thresholdAutoUpdate: true,
|
||||||
statements: 72.2,
|
statements: 72.66,
|
||||||
branches: 61.18,
|
branches: 61.9,
|
||||||
functions: 73.47,
|
functions: 73.96,
|
||||||
lines: 72.09,
|
lines: 72.56,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user