Add initial keyboard shortcut support.

This commit is contained in:
Dermot Duffy
2024-06-05 21:44:17 -07:00
parent 904f6d8142
commit 7ab545738d
186 changed files with 9564 additions and 3658 deletions
@@ -0,0 +1,83 @@
import { LitElement, ReactiveController } from 'lit';
import isEqual from 'lodash-es/isEqual';
import { KeyboardShortcut } from '../config/keyboard-shortcuts';
import { setOrRemoveAttribute } from '../utils/basic';
export class KeyAssignerController implements ReactiveController {
protected _host: LitElement;
protected _assigning = false;
protected _value: KeyboardShortcut | null = null;
constructor(host: LitElement) {
this._host = host;
this._host.addController(this);
}
public setValue(value: KeyboardShortcut | null): void {
if (!isEqual(value, this._value)) {
this._value = value;
this._host.requestUpdate();
this._host.dispatchEvent(
new CustomEvent('value-changed', {
detail: {
value: this._value,
},
}),
);
}
}
public getValue(): KeyboardShortcut | null {
return this._value;
}
public hasValue(): boolean {
return !!this._value;
}
public isAssigning(): boolean {
return this._assigning;
}
public toggleAssigning(): void {
this._setAssigning(!this._assigning);
}
protected _setAssigning(assigning: boolean): void {
this._assigning = assigning;
setOrRemoveAttribute(this._host, this._assigning, 'assigning');
if (this._assigning) {
this._host.addEventListener('keydown', this._keydownEventHandler);
} else {
this._host.removeEventListener('keydown', this._keydownEventHandler);
}
this._host.requestUpdate();
}
protected _blurEventHandler = (): void => {
this._setAssigning(false);
};
protected _keydownEventHandler = (ev: KeyboardEvent): void => {
// Don't allow _only_ a modifier.
if (!ev.key || ['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) {
return;
}
this.setValue({
key: ev.key,
ctrl: ev.ctrlKey,
alt: ev.altKey,
shift: ev.shiftKey,
meta: ev.metaKey,
});
this._setAssigning(false);
};
public hostConnected(): void {
this._host.addEventListener('blur', this._blurEventHandler);
}
public hostDisconnected(): void {
this._host.removeEventListener('blur', this._blurEventHandler);
}
}
@@ -20,7 +20,6 @@ interface LiveViewContext {
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
ptzVisible?: boolean;
fetchThumbnails?: boolean;
}
+78 -89
View File
@@ -14,17 +14,17 @@ import { FRIGATE_BUTTON_MENU_ICON } from '../const';
import { localize } from '../localize/localize.js';
import { MediaLoadedInfo } from '../types';
import {
createFrigateCardCameraAction,
createFrigateCardChangeZoomAction,
createFrigateCardDisplayModeAction,
createFrigateCardMediaPlayerAction,
createFrigateCardShowPTZAction,
createFrigateCardSimpleAction,
createCameraAction,
createPTZMultiAction,
createDisplayModeAction,
createMediaPlayerAction,
createPTZControlsAction,
createGeneralAction,
} from '../utils/action';
import { isTruthy } from '../utils/basic';
import { getEntityIcon, getEntityTitle } from '../utils/ha';
import { hasUsablePTZ } from '../utils/ptz';
import { hasSubstream } from '../utils/substream';
import { getPTZTarget } from '../utils/ptz';
import { getStreamCameraID, hasSubstream } from '../utils/substream';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
@@ -95,8 +95,8 @@ export class MenuButtonController {
this._getMuteUnmuteButton(config, options?.currentMediaLoadedInfo),
this._getScreenshotButton(config, options?.currentMediaLoadedInfo),
this._getDisplayModeButton(config, cameraManager, view),
this._getPTZButton(config, cameraManager, view),
this._getDefaultZoomButton(config, view),
this._getPTZControlsButton(config, cameraManager, view),
this._getPTZHomeButton(config, cameraManager, view),
...this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(config, view, button, options),
@@ -115,11 +115,9 @@ export class MenuButtonController {
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,
? (createGeneralAction('menu_toggle') as FrigateCardCustomAction)
: (createGeneralAction('default') as FrigateCardCustomAction),
hold_action: createGeneralAction('diagnostics') as FrigateCardCustomAction,
};
}
@@ -135,7 +133,7 @@ export class MenuButtonController {
const menuItems = Array.from(
cameraManager.getStore().getCameraConfigEntries(menuCameraIDs),
([cameraID, config]) => {
const action = createFrigateCardCameraAction('camera_select', cameraID);
const action = createCameraAction('camera_select', cameraID);
const metadata = cameraManager.getCameraMetadata(cameraID);
return {
@@ -175,7 +173,7 @@ export class MenuButtonController {
(cameraID) => cameraID !== view.camera,
);
const streams = [view.camera, ...substreams];
const substreamAwareCameraID = this._getSubstreamAwareCameraID(view);
const substreamAwareCameraID = getStreamCameraID(view);
if (streams.length === 2) {
// If there are only two dependencies (the main camera, and 1 other)
@@ -187,16 +185,13 @@ export class MenuButtonController {
title: localize('config.menu.buttons.substreams'),
...config.menu.buttons.substreams,
type: 'custom:frigate-card-menu-icon',
tap_action: createFrigateCardSimpleAction(
tap_action: createGeneralAction(
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
) as FrigateCardCustomAction,
};
} else if (streams.length > 2) {
const menuItems = Array.from(streams, (streamID) => {
const action = createFrigateCardCameraAction(
'live_substream_select',
streamID,
);
const action = createCameraAction('live_substream_select', streamID);
const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined;
const cameraConfig = cameraManager.getStore().getCameraConfig(streamID);
return {
@@ -236,7 +231,7 @@ export class MenuButtonController {
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.live'),
style: view.is('live') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
tap_action: createGeneralAction('live') as FrigateCardCustomAction,
}
: null;
}
@@ -253,8 +248,8 @@ export class MenuButtonController {
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,
tap_action: createGeneralAction('clips') as FrigateCardCustomAction,
hold_action: createGeneralAction('clip') as FrigateCardCustomAction,
}
: null;
}
@@ -271,12 +266,8 @@ export class MenuButtonController {
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,
tap_action: createGeneralAction('snapshots') as FrigateCardCustomAction,
hold_action: createGeneralAction('snapshot') as FrigateCardCustomAction,
}
: null;
}
@@ -293,12 +284,8 @@ export class MenuButtonController {
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,
tap_action: createGeneralAction('recordings') as FrigateCardCustomAction,
hold_action: createGeneralAction('recording') as FrigateCardCustomAction,
}
: null;
}
@@ -315,7 +302,7 @@ export class MenuButtonController {
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.image'),
style: view?.is('image') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
tap_action: createGeneralAction('image') as FrigateCardCustomAction,
}
: null;
}
@@ -332,9 +319,7 @@ export class MenuButtonController {
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.timeline'),
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardSimpleAction(
'timeline',
) as FrigateCardCustomAction,
tap_action: createGeneralAction('timeline') as FrigateCardCustomAction,
}
: null;
}
@@ -358,7 +343,7 @@ export class MenuButtonController {
...config.menu.buttons.download,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.download'),
tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
tap_action: createGeneralAction('download') as FrigateCardCustomAction,
};
}
return null;
@@ -374,9 +359,7 @@ export class MenuButtonController {
...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,
tap_action: createGeneralAction('camera_ui') as FrigateCardCustomAction,
}
: null;
}
@@ -402,16 +385,16 @@ export class MenuButtonController {
style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
...(!forbidden &&
buttonType === 'momentary' && {
start_tap_action: createFrigateCardSimpleAction(
start_tap_action: createGeneralAction(
'microphone_unmute',
) as FrigateCardCustomAction,
end_tap_action: createFrigateCardSimpleAction(
end_tap_action: createGeneralAction(
'microphone_mute',
) as FrigateCardCustomAction,
}),
...(!forbidden &&
buttonType === 'toggle' && {
tap_action: createFrigateCardSimpleAction(
tap_action: createGeneralAction(
muted ? 'microphone_unmute' : 'microphone_mute',
) as FrigateCardCustomAction,
}),
@@ -429,7 +412,7 @@ export class MenuButtonController {
...config.menu.buttons.expand,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.expand'),
tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
tap_action: createGeneralAction('expand') as FrigateCardCustomAction,
style: inExpandedMode ? this._getEmphasizedStyle() : {},
};
}
@@ -444,9 +427,7 @@ export class MenuButtonController {
...config.menu.buttons.fullscreen,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.fullscreen'),
tap_action: createFrigateCardSimpleAction(
'fullscreen',
) as FrigateCardCustomAction,
tap_action: createGeneralAction('fullscreen') as FrigateCardCustomAction,
style: inFullscreenMode ? this._getEmphasizedStyle() : {},
}
: null;
@@ -469,8 +450,8 @@ export class MenuButtonController {
.map((playerEntityID) => {
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
const state = hass.states[playerEntityID];
const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
const playAction = createMediaPlayerAction(playerEntityID, 'play');
const stopAction = createMediaPlayerAction(playerEntityID, 'stop');
const disabled = !state || state.state === 'unavailable';
return {
@@ -512,7 +493,7 @@ export class MenuButtonController {
...config.menu.buttons.play,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.play'),
tap_action: createFrigateCardSimpleAction(
tap_action: createGeneralAction(
paused ? 'play' : 'pause',
) as FrigateCardCustomAction,
};
@@ -535,7 +516,7 @@ export class MenuButtonController {
...config.menu.buttons.mute,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.mute'),
tap_action: createFrigateCardSimpleAction(
tap_action: createGeneralAction(
muted ? 'unmute' : 'mute',
) as FrigateCardCustomAction,
};
@@ -553,9 +534,7 @@ export class MenuButtonController {
...config.menu.buttons.screenshot,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.screenshot'),
tap_action: createFrigateCardSimpleAction(
'screenshot',
) as FrigateCardCustomAction,
tap_action: createGeneralAction('screenshot') as FrigateCardCustomAction,
};
}
return null;
@@ -577,65 +556,75 @@ export class MenuButtonController {
title: isGrid
? localize('display_modes.single')
: localize('display_modes.grid'),
tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
tap_action: createDisplayModeAction(isGrid ? 'single' : 'grid'),
};
}
return null;
}
protected _getSubstreamAwareCameraID(view: View): string {
return view.is('live')
? view.context?.live?.overrides?.get(view.camera) ?? view.camera
: view.camera;
}
protected _getPTZButton(
protected _getPTZControlsButton(
config: FrigateCardConfig,
cameraManager: CameraManager,
view: View,
): MenuItem | null {
const substreamAwareCameraCapabilities = cameraManager.getCameraCapabilities(
this._getSubstreamAwareCameraID(view),
);
const ptzConfig = view.is('live')
? config.live.controls.ptz
: view.isViewerView()
? config.media_viewer.controls.ptz
: null;
if (
view.is('live') &&
hasUsablePTZ(substreamAwareCameraCapabilities, config.live.controls.ptz)
) {
if (!ptzConfig || ptzConfig.mode === 'off') {
return null;
}
const ptzTarget = getPTZTarget(view, {
cameraManager: cameraManager,
...(ptzConfig.mode === 'auto' && { type: 'ptz' }),
});
if (ptzTarget) {
const isOn =
view.context?.live?.ptzVisible === false
? false
: config.live.controls.ptz.mode === 'on';
view.context?.ptzControls?.enabled !== false &&
(ptzConfig.mode === 'on' ||
(ptzConfig.mode === 'auto' && ptzTarget.type === 'ptz'));
return {
icon: 'mdi:pan',
...config.menu.buttons.ptz,
...config.menu.buttons.ptz_controls,
style: isOn ? this._getEmphasizedStyle() : {},
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.ptz'),
tap_action: createFrigateCardShowPTZAction(!isOn),
title: localize('config.menu.buttons.ptz_controls'),
tap_action: createPTZControlsAction(!isOn),
};
}
return null;
}
protected _getDefaultZoomButton(
protected _getPTZHomeButton(
config: FrigateCardConfig,
cameraManager: CameraManager,
view: View,
): MenuItem | null {
const targetID = view.isViewerView()
? view.queryResults?.getSelectedResult()?.getID() ?? null
: this._getSubstreamAwareCameraID(view);
const target = getPTZTarget(view, {
cameraManager: cameraManager,
});
if (!targetID || (view.context?.zoom?.[targetID]?.isDefault ?? true)) {
if (
!target ||
((target.type === 'digital' &&
view.context?.zoom?.[target.targetID]?.observed?.isDefault) ??
true)
) {
return null;
}
return {
icon: 'mdi:magnify-close',
...config.menu.buttons.default_zoom,
icon: 'mdi:home',
...config.menu.buttons.ptz_home,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.default_zoom'),
tap_action: createFrigateCardChangeZoomAction(targetID) as FrigateCardCustomAction,
title: localize('config.menu.buttons.ptz_home'),
tap_action: createPTZMultiAction({
targetID: target.targetID,
}) as FrigateCardCustomAction,
};
}
+7 -5
View File
@@ -10,12 +10,12 @@ import type {
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
import { StateParameters } from '../types.js';
import {
convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig,
convertActionToCardCustomAction,
getActionConfigGivenAction,
} from '../utils/action';
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
import { refreshDynamicStateParameters } from '../utils/ha/index.js';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
export class MenuController {
protected _host: LitElement;
@@ -95,7 +95,6 @@ export class MenuController {
}
public actionHandler(
hass: HomeAssistant,
ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
config?: ActionsConfig,
): void {
@@ -134,7 +133,10 @@ export class MenuController {
}
if (toggleLessActions.length) {
frigateCardHandleActionConfig(this._host, hass, config, interaction, actions);
dispatchActionExecutionRequest(this._host, {
action: actions,
config: config,
});
}
if (this._isHidingMenu()) {
@@ -209,7 +211,7 @@ export class MenuController {
}
protected _isMenuToggleAction(action: ActionType): boolean {
const frigateCardAction = convertActionToFrigateCardCustomAction(action);
const frigateCardAction = convertActionToCardCustomAction(action);
return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle';
}
}
-170
View File
@@ -1,170 +0,0 @@
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraManager } from '../camera-manager/manager';
import {
Actions,
ActionsConfig,
FrigateCardPTZAction,
FrigateCardPTZActions,
FrigateCardPTZConfig,
PTZAction,
PTZControlAction,
PTZ_CONTROL_ACTIONS,
} from '../config/types';
import {
frigateCardHandleActionConfig,
getActionConfigGivenAction,
} from '../utils/action';
export class PTZController {
private _host: HTMLElement;
private _config: FrigateCardPTZConfig | null = null;
private _hass: HomeAssistant | null = null;
private _cameraManager: CameraManager | null = null;
private _cameraID: string | null = null;
private _actions: FrigateCardPTZActions | null = null;
private _forceVisibility?: boolean;
constructor(host: HTMLElement) {
this._host = host;
}
public setConfig(config?: FrigateCardPTZConfig) {
this._config = config ?? null;
this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal');
this._host.setAttribute('data-position', config?.position ?? 'bottom-right');
this._host.setAttribute(
'style',
Object.entries(config?.style ?? {})
.map(([k, v]) => `${k}:${v}`)
.join(';'),
);
}
public getConfig(): FrigateCardPTZConfig | null {
return this._config;
}
public setHASS(hass?: HomeAssistant) {
this._hass = hass ?? null;
}
public setCamera(cameraManager?: CameraManager, cameraID?: string) {
this._cameraManager = cameraManager ?? null;
this._cameraID = cameraID ?? null;
this._calculateActions();
}
public setForceVisibility(forceVisibility?: boolean): void {
this._forceVisibility = forceVisibility;
}
public handleAction(
ev: HASSDomEvent<{ action: string }>,
config?: ActionsConfig | null,
): void {
// Nothing else has the configuration for this action, so don't let it
// propagate further.
ev.stopPropagation();
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
if (config && action && this._hass) {
frigateCardHandleActionConfig(this._host, this._hass, config, interaction, action);
}
}
public getPTZActions(actionName: PTZControlAction): Actions | null {
const propertyName = 'actions_' + actionName;
return this._config?.[propertyName] ?? this._actions?.[propertyName] ?? null;
}
private _hasAnyAction(): boolean {
for (const actionName of PTZ_CONTROL_ACTIONS) {
if ('actions_' + actionName in (this._actions ?? {})) {
return true;
}
}
return false;
}
public shouldDisplay(): boolean {
return this._forceVisibility === false
? false
: this._config?.mode === 'on' && this._hasAnyAction();
}
private _calculateActions(): void {
const getDefaultAction = (
ptzAction: PTZAction,
options?: {
phase?: 'start' | 'stop';
preset?: string;
},
): FrigateCardPTZAction => ({
action: 'fire-dom-event',
frigate_card_action: 'ptz',
ptz_action: ptzAction,
...(options?.phase && { ptz_phase: options.phase }),
...(options?.preset && { ptz_preset: options.preset }),
});
const getDefaultActions = (
ptzAction: PTZAction,
continuous: boolean,
preset?: string,
): Actions =>
continuous
? {
start_tap_action: getDefaultAction(ptzAction, {
phase: 'start',
preset: preset,
}),
end_tap_action: getDefaultAction(ptzAction, {
phase: 'stop',
preset: preset,
}),
}
: {
tap_action: getDefaultAction(ptzAction, { preset: preset }),
};
if (!this._cameraManager || !this._cameraID) {
return;
}
const ptzCapabilities = this._cameraManager.getCameraCapabilities(
this._cameraID,
)?.getPTZCapabilities();
const defaultActions: FrigateCardPTZActions = {};
const panTilt = ptzCapabilities?.panTilt;
const zoom = ptzCapabilities?.zoom;
const presets = ptzCapabilities?.presets;
if (panTilt?.length) {
const continuous = panTilt.includes('continuous');
defaultActions.actions_up = getDefaultActions('up', continuous);
defaultActions.actions_down = getDefaultActions('down', continuous);
defaultActions.actions_left = getDefaultActions('left', continuous);
defaultActions.actions_right = getDefaultActions('right', continuous);
}
if (zoom?.length) {
const continuous = zoom.includes('continuous');
defaultActions.actions_zoom_in = getDefaultActions('zoom_in', continuous);
defaultActions.actions_zoom_out = getDefaultActions('zoom_out', continuous);
}
if (presets?.length) {
defaultActions.actions_home = getDefaultActions('preset', false, presets[0]);
}
this._actions = {
...defaultActions,
...this._config,
};
}
}
+145
View File
@@ -0,0 +1,145 @@
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraManager } from '../../camera-manager/manager';
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request';
import { PTZAction } from '../../config/ptz';
import { Actions, ActionsConfig, PTZControlsConfig } from '../../config/types';
import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action';
import { PTZActionNameToMultiAction, PTZActionPresence } from './types';
export class PTZController {
private _host: HTMLElement;
private _config: PTZControlsConfig | null = null;
private _hass: HomeAssistant | null = null;
private _cameraManager: CameraManager | null = null;
private _cameraID: string | null = null;
private _forceVisibility?: boolean;
constructor(host: HTMLElement) {
this._host = host;
}
public setConfig(config?: PTZControlsConfig) {
this._config = config ?? null;
this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal');
this._host.setAttribute('data-position', config?.position ?? 'bottom-right');
this._host.setAttribute(
'style',
Object.entries(config?.style ?? {})
.map(([k, v]) => `${k}:${v}`)
.join(';'),
);
}
public getConfig(): PTZControlsConfig | null {
return this._config;
}
public setCamera(cameraManager?: CameraManager, cameraID?: string): void {
this._cameraManager = cameraManager ?? null;
this._cameraID = cameraID ?? null;
}
public setForceVisibility(forceVisibility?: boolean): void {
this._forceVisibility = forceVisibility;
}
public handleAction(
ev: HASSDomEvent<{ action: string }>,
config?: ActionsConfig | null,
): void {
// Nothing else has the configuration for this action, so don't let it
// propagate further.
ev.stopPropagation();
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
if (action) {
dispatchActionExecutionRequest(this._host, {
action: action,
...(config && { config: config }),
});
}
}
public hasUsefulAction(): PTZActionPresence {
const allUsefulActions = {
pt: true,
z: true,
home: true,
};
if (!this._cameraID) {
// Will use digital PTZ.
return allUsefulActions;
}
const capabilities = this._cameraManager?.getCameraCapabilities(this._cameraID);
if (!capabilities || !capabilities.hasPTZCapability()) {
// Will use digital PTZ.
return allUsefulActions;
}
const ptzCapabilities = capabilities.getPTZCapabilities();
return {
pt:
!!ptzCapabilities?.up ||
!!ptzCapabilities?.down ||
!!ptzCapabilities?.left ||
!!ptzCapabilities?.right,
z: !!ptzCapabilities?.zoomIn || !!ptzCapabilities?.zoomOut,
home: !!ptzCapabilities?.presets?.length,
};
}
public shouldDisplay(): boolean {
return this._forceVisibility !== undefined
? this._forceVisibility
: this._config?.mode === 'auto'
? !!this._cameraID &&
!!this._cameraManager?.getCameraCapabilities(this._cameraID)?.hasPTZCapability()
: this._config?.mode === 'on';
}
public getPTZActions(): PTZActionNameToMultiAction {
const getDefaultActions = (options?: {
ptzAction?: PTZAction;
preset?: string;
}): Actions => ({
start_tap_action: createPTZMultiAction({
ptzAction: options?.ptzAction,
ptzPhase: 'start',
ptzPreset: options?.preset,
}),
end_tap_action: createPTZMultiAction({
ptzAction: options?.ptzAction,
ptzPhase: 'stop',
ptzPreset: options?.preset,
}),
});
const actions: PTZActionNameToMultiAction = {};
actions.up = getDefaultActions({
ptzAction: 'up',
});
actions.down = getDefaultActions({
ptzAction: 'down',
});
actions.left = getDefaultActions({
ptzAction: 'left',
});
actions.right = getDefaultActions({
ptzAction: 'right',
});
actions.zoom_in = getDefaultActions({
ptzAction: 'zoom_in',
});
actions.zoom_out = getDefaultActions({
ptzAction: 'zoom_out',
});
actions.home = {
tap_action: createPTZMultiAction(),
};
return actions;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { PTZControlAction } from '../../config/ptz';
import { Actions } from '../../config/types';
interface PTZControlsViewContext {
enabled?: boolean;
}
declare module 'view' {
interface ViewContext {
ptzControls?: PTZControlsViewContext;
}
}
export type PTZActionNameToMultiAction = {
[K in PTZControlAction]?: Actions;
};
export interface PTZActionPresence {
pt: boolean;
z: boolean;
home: boolean;
}
+24 -6
View File
@@ -1,11 +1,29 @@
export interface ZoomConfig {
pan?: {
x?: number;
y?: number;
import { PartialDeep } from 'type-fest';
export const ZOOM_DEFAULT_PAN_X = 50;
export const ZOOM_DEFAULT_PAN_Y = 50;
export const ZOOM_DEFAULT_SCALE = 1;
export const ZOOM_PRECISION = 4;
export interface ZoomSettingsBase {
pan: {
x: number;
y: number;
};
zoom?: number;
zoom: number;
}
export interface ZoomDefault {
export type PartialZoomSettings = PartialDeep<ZoomSettingsBase>;
export interface ZoomSettingsObserved extends ZoomSettingsBase {
isDefault: boolean;
unzoomed: boolean;
}
export const isZoomEmpty = (settings?: PartialZoomSettings | null): boolean => {
return (
settings?.pan?.x === undefined &&
settings?.pan?.y === undefined &&
settings?.zoom === undefined
);
};
+83 -51
View File
@@ -1,17 +1,20 @@
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';
import round from 'lodash-es/round';
import {
arefloatsApproximatelyEqual,
dispatchFrigateCardEvent,
isHoverableDevice,
} from '../../utils/basic';
import { ZoomConfig } from './types';
const ZOOM_DEFAULT_PAN_X = 50;
const ZOOM_DEFAULT_PAN_Y = 50;
const ZOOM_DEFAULT_SCALE = 1;
const ZOOM_PRECISION = 4;
import {
PartialZoomSettings,
ZOOM_DEFAULT_PAN_X,
ZOOM_DEFAULT_PAN_Y,
ZOOM_DEFAULT_SCALE,
ZOOM_PRECISION,
ZoomSettingsObserved,
isZoomEmpty,
} from './types';
export class ZoomController {
protected _element: HTMLElement;
@@ -26,15 +29,14 @@ export class ZoomController {
// Should clicks be allowed to propagate, or consumed as a pan/zoom action?
protected _allowClick = true;
protected _defaultConfig: ZoomConfig | null;
protected _config: ZoomConfig | null;
protected _defaultSettings: PartialZoomSettings | null;
protected _settings: PartialZoomSettings | null;
// When the user pans/zooms changes may be created at a very high rate.
protected _debouncedChangeHandler = debounce(this._changeHandler.bind(this), 200);
// Multiple calls to setConfig() or setDefaultConfig() or resizes should only
// update once.
protected _debouncedUpdater = debounce(this._updateBasedOnConfig.bind(this), 200);
// These values should be suitably less than the value of STEP_DELAY_SECONDS
// in the ptz-digital action, in order to ensure smooth movements of the
// digital PTZ actions.
protected _debouncedChangeHandler = throttle(this._changeHandler.bind(this), 50);
protected _debouncedUpdater = throttle(this._updateBasedOnConfig.bind(this), 50);
protected _resizeObserver = new ResizeObserver(this._debouncedUpdater);
@@ -70,6 +72,11 @@ export class ZoomController {
// handler in the viewer).
if (!this._allowClick) {
ev.stopPropagation();
// Even though the click is stopped,the card still needs to gain focus so
// that keyboard shortcuts will work immediately after the card is clicked
// upon.
dispatchFrigateCardEvent(this._element, 'focus');
}
this._allowClick = true;
};
@@ -97,11 +104,14 @@ export class ZoomController {
constructor(
element: HTMLElement,
options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null },
options?: {
config?: PartialZoomSettings | null;
defaultConfig?: PartialZoomSettings | null;
},
) {
this._element = element;
this._config = options?.config ?? null;
this._defaultConfig = options?.defaultConfig ?? null;
this._settings = options?.config ?? null;
this._defaultSettings = options?.defaultConfig ?? null;
}
public activate(): void {
@@ -182,44 +192,47 @@ export class ZoomController {
this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler);
}
public setDefaultConfig(config: ZoomConfig | null): void {
this._defaultConfig = config;
public setDefaultSettings(config: PartialZoomSettings | null): void {
this._defaultSettings = config;
this._debouncedUpdater();
}
public setConfig(config: ZoomConfig): void {
this._config = config;
public setSettings(config: PartialZoomSettings | null): void {
this._settings = config;
this._debouncedUpdater();
}
protected _changeHandler(ev: Event): void {
const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail;
const isUnzoomed = this._isUnzoomed(pz.scale);
const isAtDefault = this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale);
const unzoomed = this._isUnzoomed(pz.scale);
// Take care here to only dispatch the zoomed/unzoomed events when the
// absolute state changes (rather than on every single zoom adjustment).
if (isUnzoomed && this._zoomed) {
if (unzoomed && this._zoomed) {
this._zoomed = false;
this._setTouchAction(true);
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
} else if (!isUnzoomed && !this._zoomed) {
} else if (!unzoomed && !this._zoomed) {
this._zoomed = true;
this._setTouchAction(false);
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
}
if (isAtDefault && !this._default) {
this._default = true;
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: true });
} else if (!isAtDefault && this._default) {
this._default = false;
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: false });
}
const converted = this._convertXYPanToPercent(pz.x, pz.y, pz.scale);
const observed: ZoomSettingsObserved = {
pan: {
x: converted?.x ?? ZOOM_DEFAULT_PAN_X,
y: converted?.y ?? ZOOM_DEFAULT_PAN_Y,
},
zoom: pz.scale,
isDefault: this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale),
unzoomed: unzoomed,
};
dispatchFrigateCardEvent(this._element, 'zoom:change', observed);
}
protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean {
protected _isZoomEqual(a: PartialZoomSettings, b: PartialZoomSettings): boolean {
// The ?? clauses below cannot be reached since this function is only ever
// used fully specified by this object. It's kept as-is for completeness.
return (
@@ -247,16 +260,8 @@ export class ZoomController {
);
}
protected _isZoomEmpty(config?: ZoomConfig | null): boolean {
return (
config?.pan?.x === undefined &&
config?.pan?.y === undefined &&
config?.zoom === undefined
);
}
protected _getConfigToUse(): ZoomConfig | null {
return this._isZoomEmpty(this._config) ? this._defaultConfig : this._config;
protected _getConfigToUse(): PartialZoomSettings | null {
return isZoomEmpty(this._settings) ? this._defaultSettings : this._settings;
}
protected _updateBasedOnConfig(): void {
@@ -292,6 +297,9 @@ export class ZoomController {
}
this._panzoom.zoom(desiredScale, {
// Zoom is stepped, not animated. If it is animated, there is interaction
// between the zoom and the pan below, and the pan would need to be
// delayed until after the zoom is complete.
animate: false,
});
@@ -300,10 +308,12 @@ export class ZoomController {
// situation where we need to ensure the zoom completes first. Using
// `requestAnimationFrame` appears to reliably allow the zoom to finish
// rendering first, before the pain is applied.
//
// See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom
window.requestAnimationFrame(() => {
this._panzoom?.pan(x, y, {
animate: false,
animate: true,
duration: 100,
});
});
}
@@ -332,6 +342,28 @@ export class ZoomController {
};
}
protected _convertXYPanToPercent(
x: number,
y: number,
scale: number,
): { x: number; y: number } | null {
const minMax = this._getTransformMinMax(scale, this._panzoom?.getScale());
if (minMax === null) {
return null;
}
return {
x:
((-x + Math.abs(minMax.minX)) /
(Math.abs(minMax.maxX) + Math.abs(minMax.minX))) *
100,
y:
((-y + Math.abs(minMax.minY)) /
(Math.abs(minMax.maxY) + Math.abs(minMax.minY))) *
100,
};
}
protected _getTransformMinMax(
desiredScale: number,
currentScale?: number,
@@ -375,14 +407,14 @@ export class ZoomController {
}
protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean {
if (!this._defaultConfig) {
if (!this._defaultSettings) {
return this._isUnzoomed(scale);
}
const convertedDefault = this._convertPercentToXYPan(
this._defaultConfig.pan?.x ?? ZOOM_DEFAULT_PAN_X,
this._defaultConfig.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
this._defaultConfig.zoom ?? ZOOM_DEFAULT_SCALE,
this._defaultSettings.pan?.x ?? ZOOM_DEFAULT_PAN_X,
this._defaultSettings.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
this._defaultSettings.zoom ?? ZOOM_DEFAULT_SCALE,
);
if (!convertedDefault) {
return true;
@@ -393,7 +425,7 @@ export class ZoomController {
arefloatsApproximatelyEqual(y, convertedDefault.y) &&
arefloatsApproximatelyEqual(
scale,
this._defaultConfig.zoom ??
this._defaultSettings.zoom ??
// The ZOOM_DEFAULT_SCALE clause below cannot be reached since when
// this._defaultConfig.zoom is undefined, convertedDefault will end up
// null above and this function will have already returned.
+19 -20
View File
@@ -1,15 +1,13 @@
import { ViewContext } from 'view';
import { ZoomConfig, ZoomDefault } from './types.js';
import { dispatchViewContextChangeEvent } from '../../view/view.js';
import { ZoomSettingsObserved, PartialZoomSettings } from './types.js';
interface ZoomViewContext {
observed?: ZoomSettingsObserved;
// Populate this to request zoom to a particular scale/x/y. An empty object
// will reset to default, null will make no change.
zoom?: ZoomConfig | null;
// This will be populated with whether or not the current zoom is at the
// default level.
isDefault?: boolean;
requested?: PartialZoomSettings | null;
}
interface ZoomsViewContext {
@@ -22,36 +20,37 @@ declare module 'view' {
}
}
export const generateViewContextForZoomChange = (
export const generateViewContextForZoom = (
targetID: string,
options?: {
zoom?: ZoomConfig | null;
isDefault?: boolean;
observed?: ZoomSettingsObserved;
requested?: PartialZoomSettings | null;
},
): ViewContext | null => {
return {
zoom: {
[targetID]: {
zoom: options?.zoom ?? null,
...(options?.isDefault !== undefined && { isDefault: options.isDefault }),
observed: options?.observed ?? undefined,
requested: options?.requested ?? null,
},
},
};
};
/**
* Convenience wrapper to convert a zoom default into a dispatched view context
* Convenience wrapper to convert zoom settings into a dispatched view context
* change.
*/
export const handleZoomDefaultEvent = (
export const handleZoomSettingsObservedEvent = (
element: EventTarget,
ev: CustomEvent<ZoomDefault>,
ev: CustomEvent<ZoomSettingsObserved>,
targetID?: string,
): void => {
targetID && dispatchViewContextChangeEvent(
element,
generateViewContextForZoomChange(targetID, {
isDefault: ev.detail.isDefault,
}),
);
targetID &&
dispatchViewContextChangeEvent(
element,
generateViewContextForZoom(targetID, {
observed: ev.detail,
}),
);
};