Treat camera unavailability in a more friendly way.

Also creates a `components-lib` directory for major webcomponent support code.
This commit is contained in:
Dermot Duffy
2023-10-05 21:18:03 -07:00
parent 95e42ab767
commit d895c43e67
23 changed files with 207 additions and 89 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ export class EmblaReInitController {
this._shouldReInitOnScrollStop = false;
this._emblaApi?.reInit();
},
200,
500,
{ trailing: true },
);
}
+36
View File
@@ -0,0 +1,36 @@
import { HomeAssistant } from "custom-card-helpers";
import { HassEntity } from "home-assistant-js-websocket";
import { CameraConfig } from "../config/types.js";
import { dispatchErrorMessageEvent } from "../components/message.js";
import { localize } from "../localize/localize.js";
/**
* Get the state object or dispatch an error. Used in `ha` and `image` live
* providers.
* @param element HTMLElement to dispatch errors from.
* @param hass Home Assistant object.
* @param cameraConfig Camera configuration.
* @returns
*/
export const getStateObjOrDispatchError = (
element: HTMLElement,
hass: HomeAssistant,
cameraConfig?: CameraConfig,
): HassEntity | null => {
if (!cameraConfig?.camera_entity) {
dispatchErrorMessageEvent(element, localize('error.no_live_camera'), {
context: cameraConfig,
});
return null;
}
const stateObj = hass.states[cameraConfig.camera_entity];
if (!stateObj) {
dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), {
context: cameraConfig,
});
return null;
}
return stateObj;
};
-353
View File
@@ -1,353 +0,0 @@
import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import Masonry from 'masonry-layout';
import { ViewDisplayConfig } from '../config/types';
import { MediaLoadedInfo } from '../types';
import {
dispatchFrigateCardEvent,
getChildrenFromElement,
setOrRemoveAttribute,
} from './basic';
import {
FrigateMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from './media-info';
// The default minimum cell width: if the columns are not specified this value
// is used to compute the number of columns, always trying to keep each cell as
// at least this width. On Android, a card in portrait mode is 396 pixels, and
// we'd like to support two cells wide in that configuration.
const MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH = 190;
const MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH = 600;
const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0;
type GridID = string;
type MediaGridChild = HTMLElement & FrigateMediaLoadedEventTarget;
type MediaGridContents = Map<GridID, MediaGridChild>;
export interface MediaGridSelected {
selected: GridID;
}
export interface MediaGridConstructorOptions {
selected?: GridID;
idAttribute?: string;
displayConfig?: ViewDisplayConfig;
}
export class MediaGridController {
protected _host: HTMLElement;
protected _selected: GridID | null;
protected _mediaLoadedInfoMap: Map<GridID, MediaLoadedInfo> = new Map();
protected _gridContents: MediaGridContents = new Map();
protected _masonry: Masonry | null = null;
protected _displayConfig: ViewDisplayConfig | null = null;
protected _hostWidth: number;
protected _idAttribute: string;
protected _throttledLayout = throttle(
() => this._masonry?.layout?.(),
// Throttle layout calls to larger than the masonry.js transitionDuration
// value specified below.
300,
{ trailing: true, leading: false },
);
// If the order in which the observers are declared changes, the unittest must
// be updated in triggerResizeObserver and triggerMutationObserver.
protected _hostMutationObserver = new MutationObserver(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_mutations: MutationRecord[], _observer: MutationObserver) =>
this._calculateGridContentsFromHost(),
);
protected _cellMutationObserver = new MutationObserver(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_mutations: MutationRecord[], _observer: MutationObserver) =>
this._calculateGridContentsFromHost(),
);
protected _hostResizeObserver = new ResizeObserver(this._hostResizeHandler.bind(this));
protected _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this));
constructor(host: HTMLElement, options?: MediaGridConstructorOptions) {
this._host = host;
this._selected = options?.selected ?? null;
this._idAttribute = options?.idAttribute ?? 'grid-id';
this._hostWidth = this._host.getBoundingClientRect().width;
this._hostResizeObserver.observe(host);
this._displayConfig = options?.displayConfig ?? null;
this._hostMutationObserver.observe(host, {
childList: true,
});
// Need to separately listen for slotchanges since mutation observer will
// not be called for shadom DOM slotted changes.
if (host instanceof HTMLSlotElement) {
host.addEventListener('slotchange', this._calculateGridContentsFromHost);
}
this._calculateGridContentsFromHost();
}
public destroy(): void {
this._hostResizeObserver.disconnect();
this._cellResizeObserver.disconnect();
this._hostMutationObserver.disconnect();
this._cellMutationObserver.disconnect();
if (this._host instanceof HTMLSlotElement) {
this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost);
}
this._mediaLoadedInfoMap.clear();
this._masonry?.destroy?.();
this._masonry = null;
for (const child of this._gridContents.values()) {
this._removeChildEventListeners(child);
}
this._gridContents.clear();
}
public setDisplayConfig(displayConfig: ViewDisplayConfig | null): void {
if (!isEqual(displayConfig, this._displayConfig)) {
this._displayConfig = displayConfig;
this._calculateGridContentsFromHost();
}
}
public getGridContents(): MediaGridContents {
return this._gridContents;
}
public getGridSize(): number {
return this._gridContents.size;
}
public getSelected(): GridID | null {
return this._selected;
}
public selectCell(id: GridID) {
if (this._selected === id) {
return;
}
this._selected = id;
dispatchFrigateCardEvent(this._host, 'media-grid:selected', { selected: id });
const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id);
if (mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo);
}
this._updateSelectedStylesOnElements();
// Sizes may change when an element is selected, so re-do the layout (must
// come after the call to _updateStylesOnElements in order to ensure the
// right styles are applied first).
this._throttledLayout();
}
public unselectAll() {
if (this._selected !== null) {
dispatchMediaUnloadedEvent(this._host);
dispatchFrigateCardEvent(this._host, 'media-grid:unselected');
}
this._selected = null;
this._updateSelectedStylesOnElements();
}
protected _calculateGridContentsFromHost = (): void => {
const children = getChildrenFromElement(this._host);
const gridContents: MediaGridContents = new Map();
for (const child of children) {
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
gridContents.set(id, child);
}
this._setGridContents(gridContents);
};
protected _setGridContents(gridContents: MediaGridContents): void {
this._gridContents = gridContents;
// Remove media loaded info objects that belong to objects no longer in the
// grid.
for (const key of this._mediaLoadedInfoMap.keys()) {
if (!gridContents.has(key)) {
this._mediaLoadedInfoMap.delete(key);
}
}
if (this._selected !== null && !this._gridContents.has(this._selected)) {
this.unselectAll();
}
for (const element of gridContents.values()) {
this._removeChildEventListeners(element);
this._addChildEventListeners(element);
}
this._setColumnSizeStyles();
this._createMasonry();
// Observe grid elements for size or id changes.
this._cellMutationObserver.disconnect();
this._cellResizeObserver.disconnect();
for (const child of gridContents.values()) {
this._cellMutationObserver.observe(child, {
attributeFilter: [this._idAttribute],
attributes: true,
});
this._cellResizeObserver.observe(child);
}
this._updateSelectedStylesOnElements();
this._setColumnSizeStyles();
}
protected _handleMediaLoadedInfoEvent = (ev: CustomEvent<MediaLoadedInfo>): void => {
const eventPath = ev.composedPath();
for (const [id, element] of this._gridContents.entries()) {
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (eventPath.includes(element)) {
this._mediaLoadedInfoMap.set(id, ev.detail);
if (id !== this._selected) {
ev.stopPropagation();
}
break;
}
}
};
protected _hostResizeHandler(): void {
const dimensions = this._host.getBoundingClientRect();
// Only resize things if the width has changed. It is expected that the
// height may change during the layout.
if (dimensions.width !== this._hostWidth) {
this._hostWidth = dimensions.width;
// Reset the column CSS sizes first.
this._setColumnSizeStyles();
// Need to recreate the masonry layout since the column width will differ.
this._createMasonry();
}
}
protected _cellResizeHandler(): void {
this._throttledLayout();
}
protected _addChildEventListeners(child: MediaGridChild): void {
child.addEventListener('click', this._handleSelectGridCellEvent, {
capture: true,
});
child.addEventListener(
'frigate-card:media:loaded',
this._handleMediaLoadedInfoEvent,
);
}
protected _removeChildEventListeners(child: MediaGridChild): void {
child.removeEventListener('click', this._handleSelectGridCellEvent, {
capture: true,
});
child.removeEventListener(
'frigate-card:media:loaded',
this._handleMediaLoadedInfoEvent,
);
}
protected _createMasonry(): void {
if (this._masonry) {
this._masonry.destroy?.();
}
this._masonry = new Masonry(this._host, {
columnWidth: this._getColumnSize(),
initLayout: false,
percentPosition: true,
transitionDuration: '0.2s',
});
this._masonry.addItems?.([...this._gridContents.values()]);
this._throttledLayout();
}
protected _handleSelectGridCellEvent = (ev: Event): void => {
const eventPath = ev.composedPath();
for (const [id, element] of this._gridContents.entries()) {
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (eventPath.includes(element)) {
if (this._selected !== id) {
this.selectCell(id);
ev.stopPropagation();
}
break;
}
}
};
protected _updateSelectedStylesOnElements(): void {
for (const [id, element] of this._gridContents.entries()) {
setOrRemoveAttribute(element, id === this._selected, 'selected');
// Explicitly use an 'unselected' attribute vs a :not(selected) such that
// a carousel with neither selected nor unselected will behave normally.
// This matches a css selector in viewer-carousel.scss .
setOrRemoveAttribute(element, id !== this._selected, 'unselected');
}
}
protected _getColumnSize(): number {
return Math.round(this._hostWidth / this._getColumns());
}
protected _getColumns(): number {
if (this._displayConfig?.grid_columns) {
return this._displayConfig?.grid_columns;
}
const maxColumns = this._displayConfig?.grid_max_columns ?? Infinity;
// See if we can get a multi-column layout using the ideal cell width.
const idealColumns = Math.min(
maxColumns,
Math.floor(this._hostWidth / MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH),
);
if (idealColumns > 1) {
return idealColumns;
}
// If not, get a multi-column view using the minimum cell width.
const minColumns = Math.floor(
Math.min(maxColumns, this._hostWidth / MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH),
);
// Last result use at least 1 column.
return Math.max(1, minColumns);
}
protected _setColumnSizeStyles(): void {
this._host.style.setProperty(
'--frigate-card-grid-column-size',
`${this._getColumnSize()}px`,
);
this._host.style.setProperty(
'--frigate-card-grid-selected-width-factor',
`${
this._displayConfig?.grid_selected_width_factor ??
MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR
}`,
);
}
}
-504
View File
@@ -1,504 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map';
import { CameraManager } from '../camera-manager/manager';
import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
FrigateCardCustomAction,
MenuItem
} from '../config/types';
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
import { localize } from '../localize/localize.js';
import {
MediaLoadedInfo,
} from '../types';
import { View } from '../view/view';
import { createFrigateCardCustomAction } from './action';
import { getAllDependentCameras } from './camera';
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import { getEntityIcon, getEntityTitle } from './ha';
import { hasSubstream } from './substream';
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 visibleCameras = cameraManager.getStore().getVisibleCameras();
const selectedCameraID = view.camera;
const selectedCameraConfig = cameraManager
.getStore()
.getCameraConfig(selectedCameraID);
const allSelectedCameraIDs = getAllDependentCameras(cameraManager, selectedCameraID);
const selectedMedia = view.queryResults?.getSelectedResult();
const cameraCapabilities =
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'
? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction)
: (createFrigateCardCustomAction('default') as FrigateCardCustomAction),
hold_action: createFrigateCardCustomAction(
'diagnostics',
) as FrigateCardCustomAction,
});
if (visibleCameras.size) {
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID,
});
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
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: createFrigateCardCustomAction(
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
) as FrigateCardCustomAction,
});
} else if (dependencies.length > 2) {
const menuItems = Array.from(dependencies, (cameraID) => {
const action = createFrigateCardCustomAction('live_substream_select', {
camera: 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: createFrigateCardCustomAction('live') as FrigateCardCustomAction,
});
if (cameraCapabilities?.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: createFrigateCardCustomAction('clips') as FrigateCardCustomAction,
hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction,
});
}
if (cameraCapabilities?.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: createFrigateCardCustomAction(
'snapshots',
) as FrigateCardCustomAction,
hold_action: createFrigateCardCustomAction(
'snapshot',
) as FrigateCardCustomAction,
});
}
if (cameraCapabilities?.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: createFrigateCardCustomAction(
'recordings',
) as FrigateCardCustomAction,
hold_action: createFrigateCardCustomAction(
'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: createFrigateCardCustomAction('image') as FrigateCardCustomAction,
});
// Don't show the timeline button unless there's at least one non-birdseye
// camera with a Frigate camera name.
if (cameraCapabilities?.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: createFrigateCardCustomAction('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: createFrigateCardCustomAction('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: createFrigateCardCustomAction(
'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: createFrigateCardCustomAction(
'microphone_unmute',
) as FrigateCardCustomAction,
end_tap_action: createFrigateCardCustomAction(
'microphone_mute',
) as FrigateCardCustomAction,
}),
...(!forbidden &&
buttonType === 'toggle' && {
tap_action: createFrigateCardCustomAction(
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: createFrigateCardCustomAction(
'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: createFrigateCardCustomAction('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 = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'play',
});
const stopAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: '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: createFrigateCardCustomAction(
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: createFrigateCardCustomAction(
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: createFrigateCardCustomAction(
'screenshot',
) as FrigateCardCustomAction,
});
}
if (view.supportsMultipleDisplayModes() && visibleCameras.size > 1) {
const isGrid = view.isGrid();
const action = createFrigateCardCustomAction('display_mode_select', {
display_mode: isGrid ? 'single' : 'grid',
});
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (action) {
buttons.push({
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
...config.menu.buttons.display_mode,
type: 'custom:frigate-card-menu-icon',
title: isGrid
? localize('display_modes.single')
: localize('display_modes.grid'),
tap_action: action,
});
}
}
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\//);
}
}
-257
View File
@@ -1,257 +0,0 @@
import add from 'date-fns/add';
import sub from 'date-fns/sub';
import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { CameraManager } from '../camera-manager/manager';
import {
ExpiringMemoryRangeSet,
MemoryRangeSet,
compressRanges,
} from '../camera-manager/range';
import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ClipsOrSnapshotsOrAll } from '../types';
import { ViewMedia } from '../view/media';
import { ModifyInterface, errorToConsole } from './basic.js';
// Allow timeline freshness to be at least this number of seconds out of date
// (caching times in the data-engine may increase the effective delay).
const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
// Number of seconds gap allowable in order to consider two recording segments
// to be consecutive. Some low performance cameras have trouble and without a
// generous allowance here the timeline may be littered with individual segments
// instead of clean recording blocks.
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
export interface FrigateCardTimelineItem extends TimelineItem {
// Use numbers to avoid significant volumes of Date object construction (for
// high-quantity recording segments).
start: number;
end?: number;
media?: ViewMedia;
}
export class TimelineDataSource {
protected _cameraManager: CameraManager;
protected _dataset: DataSet<FrigateCardTimelineItem> = new DataSet();
// The ranges in which recordings have been calculated and added for.
// Calculating recordings is a very expensive process since it is based on
// segments (not just the fetch is expensive, but the JS to dedup and turn the
// high-N segments into a smaller number of consecutive recording blocks).
protected _recordingRanges = new MemoryRangeSet();
// Cache event ranges since re-adding the same events is a timeline
// performance killer (even if the request results are cached).
protected _eventRanges = new ExpiringMemoryRangeSet();
protected _cameraIDs: Set<string>;
protected _mediaType: ClipsOrSnapshotsOrAll;
protected _showRecordings: boolean;
constructor(
cameraManager: CameraManager,
cameraIDs: Set<string>,
media: ClipsOrSnapshotsOrAll,
showRecordings: boolean,
) {
this._cameraManager = cameraManager;
this._cameraIDs = cameraIDs;
this._mediaType = media;
this._showRecordings = showRecordings;
}
get dataset(): DataSet<FrigateCardTimelineItem> {
return this._dataset;
}
public rewriteEvent(id: IdType): void {
// Hack: For timeline uses of the event dataset clustering may not update
// unless the dataset changes, artifically update the dataset to ensure the
// newly selected item cannot be included in a cluster.
// Hack2: Cannot use `updateOnly` here, as vis-data loses the object
// prototype, see: https://github.com/visjs/vis-data/issues/997 . Instead,
// remove then add.
const item = this._dataset.get(id);
if (item) {
this._dataset.remove(id);
this._dataset.add(item);
}
}
public async refresh(window: TimelineWindow): Promise<void> {
try {
await Promise.all([
this._refreshEvents(window),
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
]);
} catch (e) {
errorToConsole(e as Error);
// Intentionally ignore errors here, since it is likely the user will
// change the range again and a subsequent call may work. To do otherwise
// would be jarring to the timeline experience in the case of transient
// errors from the backend.
}
}
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
start: window.start,
end: window.end,
...(this._mediaType === 'clips' && { hasClip: true }),
...(this._mediaType === 'snapshots' && { hasSnapshot: true }),
});
}
public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null {
return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, {
start: window.start,
end: window.end,
});
}
protected async _refreshEvents(window: TimelineWindow): Promise<void> {
if (
this._eventRanges.hasCoverage({
start: window.start,
end: sub(capEndDate(window.end), {
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
}),
})
) {
return;
}
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
if (!eventQueries) {
return;
}
const mediaArray = await this._cameraManager.executeMediaQueries(eventQueries);
const data: FrigateCardTimelineItem[] = [];
for (const media of mediaArray ?? []) {
const startTime = media.getStartTime();
const id = media.getID();
if (id && startTime) {
data.push({
id: id,
group: media.getCameraID(),
content: '',
media: media,
start: startTime.getTime(),
type: 'range',
end: media.getUsableEndTime()?.getTime() ?? startTime.getTime(),
});
}
}
this._dataset.update(data);
this._eventRanges.add({
...cacheFriendlyWindow,
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
});
}
protected async _refreshRecordings(window: TimelineWindow): Promise<void> {
type FrigateCardTimelineItemWithEnd = ModifyInterface<
FrigateCardTimelineItem,
{ end: number }
>;
const convertSegmentToRecording = (
cameraID: string,
segment: RecordingSegment,
): FrigateCardTimelineItemWithEnd => {
return {
id: `recording-${cameraID}-${segment.id}`,
group: cameraID,
start: segment.start_time * 1000,
end: segment.end_time * 1000,
content: '',
type: 'background',
};
};
const getExistingRecordingsForCameraID = (
cameraID: string,
): FrigateCardTimelineItemWithEnd[] => {
return this._dataset.get({
filter: (item) =>
item.type == 'background' && item.group === cameraID && item.end !== undefined,
}) as FrigateCardTimelineItemWithEnd[];
};
const deleteRecordingsForCameraID = (cameraID: string): void => {
this._dataset.remove(
this._dataset.get({
filter: (item) => item.type === 'background' && item.group === cameraID,
}),
);
};
const addRecordings = (recordings: FrigateCardTimelineItemWithEnd[]): void => {
this._dataset.add(recordings);
};
// Calculate an end date that's slightly short of the current time to allow
// for caching up to the freshness tolerance.
if (
this._recordingRanges.hasCoverage({
start: window.start,
end: sub(capEndDate(window.end), {
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
}),
})
) {
return;
}
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
this._cameraIDs,
{
start: cacheFriendlyWindow.start,
end: cacheFriendlyWindow.end,
},
);
if (!recordingQueries) {
return;
}
const results = await this._cameraManager.getRecordingSegments(recordingQueries);
const newSegments: Map<string, RecordingSegment[]> = new Map();
for (const [query, result] of results) {
for (const cameraID of query.cameraIDs) {
let destination: RecordingSegment[] | undefined = newSegments.get(cameraID);
if (!destination) {
destination = [];
newSegments.set(cameraID, destination);
}
result.segments.forEach((segment) => destination?.push(segment));
}
}
for (const [cameraID, segments] of newSegments.entries()) {
const existingRecordings = getExistingRecordingsForCameraID(cameraID);
const mergedRecordings = existingRecordings.concat(
segments.map((segment) => convertSegmentToRecording(cameraID, segment)),
);
const compressedRecordings = compressRanges(
mergedRecordings,
TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS,
) as FrigateCardTimelineItemWithEnd[];
deleteRecordingsForCameraID(cameraID);
addRecordings(compressedRecordings);
}
this._recordingRanges.add({
start: cacheFriendlyWindow.start,
end: cacheFriendlyWindow.end,
});
}
}
-160
View File
@@ -1,160 +0,0 @@
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import round from 'lodash-es/round';
import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
export class Zoom {
constructor(element: HTMLElement) {
this._element = element;
}
protected _element: HTMLElement;
protected _panzoom?: PanzoomObject;
protected _zoomed = false;
protected _allowClick = true;
protected _events = isHoverableDevice()
? {
down: ['pointerdown'],
move: ['pointermove'],
up: ['pointerup', 'pointerleave', 'pointercancel'],
}
: {
down: ['touchstart'],
move: ['touchmove'],
up: ['touchend', 'touchcancel'],
};
protected _downHandler = (ev: Event) => {
if (this._shouldZoomOrPan(ev)) {
this._panzoom?.handleDown(ev as PointerEvent);
ev.stopPropagation();
// If we do not prevent default here, the media carousels scroll.
ev.preventDefault();
this._allowClick = false;
} else {
this._allowClick = true;
}
};
protected _clickHandler = (ev: Event) => {
// When mouse clicking is used to pan, need to avoid that causing a click
// handler elsewhere in the card being called. Example: Viewing a snapshot,
// and panning within it should not cause a related clip to play (the click
// handler in the viewer).
if (!this._allowClick) {
ev.stopPropagation();
}
this._allowClick = true;
};
protected _moveHandler = (ev: Event) => {
if (this._shouldZoomOrPan(ev)) {
this._panzoom?.handleMove(ev as PointerEvent);
ev.stopPropagation();
}
};
protected _upHandler = (ev: Event) => {
if (this._shouldZoomOrPan(ev)) {
this._panzoom?.handleUp(ev as PointerEvent);
ev.stopPropagation();
}
};
protected _wheelHandler = (ev: Event) => {
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
this._panzoom?.zoomWithWheel(ev);
ev.stopPropagation();
}
};
protected _isScaleNormal(scale?: number): boolean {
// Floating point arithmetic warning: comparing floating point numbers,
// round them first.
return scale !== undefined && round(scale, 4) <= 1;
}
protected _shouldZoomOrPan(ev: Event): boolean {
return (
!this._isScaleNormal(this._panzoom?.getScale()) ||
// TouchEvent does not exist on Firefox on non-touch events.
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1174
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
(ev instanceof WheelEvent && ev.ctrlKey)
);
}
protected _setTouchAction(touchEnabled: boolean): void {
this._element.style.touchAction = touchEnabled ? '' : 'none';
}
public activate(): void {
this._panzoom = Panzoom(this._element, {
contain: 'outside',
maxScale: 10,
minScale: 1,
noBind: true,
// Do not force the cursor style (by default it will always show the
// 'move' type cursor whether or not it is zoomed in).
cursor: undefined,
// Disable automatic touchAction setting from Panzoom() as otherwise it
// effectively disables dashboard scrolling.
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1181
touchAction: '',
});
const registerListeners = (
events: string[],
func: (ev: Event) => void,
options?: AddEventListenerOptions,
) => {
events.forEach((eventName) => {
this._element.addEventListener(eventName, func, options);
});
};
registerListeners(this._events['down'], this._downHandler, { capture: true });
registerListeners(this._events['move'], this._moveHandler, { capture: true });
registerListeners(this._events['up'], this._upHandler, { capture: true });
registerListeners(['wheel'], this._wheelHandler);
registerListeners(['click'], this._clickHandler, { capture: true });
this._element.addEventListener('panzoomzoom', (ev: Event) => {
// Take care here to only dispatch the zoomed/unzoomed events when the
// absolute state changes (rather than on every single zoom adjustment).
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
if (this._zoomed) {
this._setTouchAction(true);
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
}
this._zoomed = false;
} else {
if (!this._zoomed) {
this._setTouchAction(false);
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
}
this._zoomed = true;
}
});
}
public deactivate(): void {
const unregisterListener = (
events: string[],
func: (ev: Event) => void,
options?: EventListenerOptions,
) => {
events.forEach((eventName) => {
this._element.removeEventListener(eventName, func, options);
});
};
unregisterListener(this._events['down'], this._downHandler, { capture: true });
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
unregisterListener(this._events['up'], this._upHandler, { capture: true });
unregisterListener(['wheel'], this._wheelHandler);
unregisterListener(['click'], this._clickHandler, { capture: true });
}
}