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,121 @@
import { z } from 'zod';
import { Actions, ActionsConfig, ActionType } from '../../config/types.js';
import { getActionConfigGivenAction } from '../../utils/action.js';
import { ActionSet } from './actions/set.js';
import { CardActionsManagerAPI } from '../types.js';
import { ActionExecutionRequest, AuxillaryActionConfig } from './types.js';
import { ActionContext } from 'action';
const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
export type InteractionName = (typeof INTERACTIONS)[number];
const interactionSchema = z.object({
action: z.enum(INTERACTIONS),
});
export type Interaction = z.infer<typeof interactionSchema>;
const interactionEventSchema = z.object({
detail: interactionSchema,
});
export class ActionsManager {
protected _api: CardActionsManagerAPI;
protected _actionsInFlight: ActionSet[] = [];
protected _actionContext: ActionContext = {};
constructor(api: CardActionsManagerAPI) {
this._api = api;
}
/**
* Merge card-wide and view-specific actions.
* @returns A combined set of action.
*/
public getMergedActions(): ActionsConfig {
const view = this._api.getViewManager().getView();
if (this._api.getMessageManager().hasMessage()) {
return {};
}
const config = this._api.getConfigManager().getConfig();
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
} else if (view?.is('image')) {
specificActions = config?.image?.actions;
} else {
return {};
}
return { ...config?.view.actions, ...specificActions };
}
/**
* Handle an human interaction called on an element (e.g. 'tap').
*/
public handleInteractionEvent = (ev: Event): void => {
const result = interactionEventSchema.safeParse(ev);
if (!result.success) {
return;
}
const interaction = result.data.detail.action;
const config = this.getMergedActions();
const actionConfig = getActionConfigGivenAction(interaction, config);
if (
config &&
interaction &&
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
// action defined (as it uses a default that is unhelpful for views that
// have default tap/click actions).
actionConfig
) {
this.executeActions(actionConfig, config);
}
};
/**
* This method is called when an ll-custom event is fired. This is used by
* cards to fire custom actions. This card itself should not call this, but
* embedded elements may.
*/
public handleCustomActionEvent = (ev: Event): void => {
if (!('detail' in ev)) {
// The event may not be a CustomEvent object, see:
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
return;
}
this.executeActions(ev.detail as ActionType);
};
/**
* This method handles actions requested by components of the Frigate card
* itself (e.g. menu, PTZ controller).
*/
public handleActionExecutionRequestEvent = async (
ev: CustomEvent<ActionExecutionRequest>,
): Promise<void> => {
await this.executeActions(ev.detail.action, ev.detail.config);
};
public uninitialize(): void {
// If there are any long-running actions, ensure they are stopped.
this._actionsInFlight.forEach((actionSet) => actionSet.stop());
}
public async executeActions(
action: ActionType | ActionType[],
config?: AuxillaryActionConfig,
): Promise<void> {
const actionSet = new ActionSet(this._actionContext, action, {
config: config,
cardID: this._api.getConfigManager().getConfig()?.card_id,
});
this._actionsInFlight.push(actionSet);
await actionSet.execute(this._api);
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
}
}
@@ -0,0 +1,29 @@
import { ActionContext } from 'action';
import { FrigateCardCustomAction } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { Action, AuxillaryActionConfig } from '../types';
export class BaseAction<T> implements Action {
protected _context: ActionContext;
protected _action: T;
protected _config?: AuxillaryActionConfig;
constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) {
this._context = context;
this._action = action;
this._config = config;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(_api: CardActionsAPI): Promise<void> {
// Pass.
}
public async stop(): Promise<void> {
// Pass.
}
}
export class FrigateCardAction<
T extends FrigateCardCustomAction,
> extends BaseAction<T> {}
@@ -0,0 +1,26 @@
import { CameraSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
const selectCameraID =
this._action.camera ??
(this._action.triggered
? api.getTriggersManager().getMostRecentlyTriggeredCameraID()
: null);
const view = api.getViewManager().getView();
const config = api.getConfigManager().getConfig();
if (selectCameraID && view) {
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
api.getViewManager().setViewByParameters({
viewName: targetViewName,
cameraID: selectCameraID,
failSafe: true,
});
}
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class CameraUIAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getCameraURLManager().openURL();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class DefaultAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewDefault();
}
}
@@ -0,0 +1,9 @@
import { DisplayModeActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode);
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class DownloadAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getDownloadManager().downloadViewerMedia();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class ExpandAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getExpandManager().toggleExpanded();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class FullscreenAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getFullscreenManager().toggleFullscreen();
}
}
@@ -0,0 +1,20 @@
import { ActionConfig, handleActionConfig } from '@dermotduffy/custom-card-helpers';
import { CardActionsAPI } from '../../types';
import { BaseAction } from './base';
/**
* Handles generic HA (non-Frigate) actions (e.g. 'more-info')
*/
export class GenericAction extends BaseAction<ActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
const hass = api.getHASSManager().getHASS();
if (hass) {
handleActionConfig(
api.getCardElementManager().getElement(),
hass,
this._config ?? {},
this._action,
);
}
}
}
@@ -0,0 +1,10 @@
import { LogActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class LogAction extends FrigateCardAction<LogActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(_api: CardActionsAPI): Promise<void> {
console[this._action.level](this._action.message);
}
}
@@ -0,0 +1,21 @@
import { MediaPlayerActionConfig } from '../../../config/types';
import { getStreamCameraID } from '../../../utils/substream';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MediaPlayerAction extends FrigateCardAction<MediaPlayerActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
const mediaPlayer = this._action.media_player;
const mediaPlayerController = api.getMediaPlayerManager();
const view = api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
if (this._action.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer);
} else if (view?.is('live')) {
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
} else if (view?.isViewerView() && media) {
await mediaPlayerController.playMedia(mediaPlayer, media);
}
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MenuToggleAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getCardElementManager().toggleMenu();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MicrophoneMuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getMicrophoneManager().mute();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MicrophoneUnmuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMicrophoneManager().unmute();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class MuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.mute();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class PauseAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.pause();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class PlayAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.play();
}
}
@@ -0,0 +1,11 @@
import { PTZControlsActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class PTZControlsAction extends FrigateCardAction<PTZControlsActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithMergedContext({
ptzControls: { enabled: this._action.enabled },
});
}
}
@@ -0,0 +1,126 @@
import clamp from 'lodash-es/clamp';
import {
PartialZoomSettings,
ZOOM_DEFAULT_PAN_X,
ZOOM_DEFAULT_PAN_Y,
ZOOM_DEFAULT_SCALE,
} from '../../../components-lib/zoom/types';
import { generateViewContextForZoom } from '../../../components-lib/zoom/zoom-view-context';
import { PTZDigitialActionConfig, ZOOM_MAX, ZOOM_MIN } from '../../../config/types';
import { getPTZTarget } from '../../../utils/ptz';
import { Timer } from '../../../utils/timer';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
import { TargetedActionContext } from '../types';
import {
setInProgressForThisTarget,
stopInProgressForThisTarget,
} from '../utils/action-state';
const STEP_DELAY_SECONDS = 0.1;
const STEP_ZOOM = 0.1;
const STEP_PAN = 5;
declare module 'action' {
interface ActionContext {
ptzDigital?: TargetedActionContext;
}
}
export class PTZDigitalAction extends FrigateCardAction<PTZDigitialActionConfig> {
protected _timer = new Timer();
protected async _stepChange(api: CardActionsAPI, targetID: string): Promise<void> {
api.getViewManager().setViewWithMergedContext(
generateViewContextForZoom(targetID, {
requested: this._convertActionToZoomSettings(
api.getViewManager().getView()?.context?.zoom?.[targetID]?.observed,
),
}),
);
}
public async stop(): Promise<void> {
this._timer.stop();
}
public async execute(api: CardActionsAPI): Promise<void> {
const view = api.getViewManager().getView();
if (!view) {
return;
}
const targetID =
this._action.target_id ??
getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() })
?.targetID;
if (!targetID) {
return;
}
if (!!this._action.absolute || !this._action.ptz_phase) {
return await this._stepChange(api, targetID);
}
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (this._action.ptz_phase === 'start') {
stopInProgressForThisTarget(targetID, this._context.ptzDigital);
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
await this._stepChange(api, targetID);
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
this._stepChange(api, targetID),
);
} else if (this._action.ptz_phase === 'stop') {
stopInProgressForThisTarget(targetID, this._context.ptzDigital);
delete this._context.ptzDigital?.[targetID];
}
}
protected _convertActionToZoomSettings(
base?: PartialZoomSettings,
): PartialZoomSettings {
if (!this._action.absolute && !this._action.ptz_action) {
// If neither an absolute position nor an action are specified, the request
// is assumed to be to return to default.
return {};
}
if (this._action.absolute) {
return this._action.absolute;
}
const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE;
const pan = {
x: base?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
y: base?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
};
const zoomDelta =
this._action.ptz_action === 'zoom_in'
? STEP_ZOOM
: this._action.ptz_action === 'zoom_out'
? -STEP_ZOOM
: 0;
const xDelta =
this._action.ptz_action === 'left'
? -STEP_PAN
: this._action.ptz_action === 'right'
? STEP_PAN
: 0;
const yDelta =
this._action.ptz_action === 'up'
? -STEP_PAN
: this._action.ptz_action === 'down'
? STEP_PAN
: 0;
return {
zoom: clamp(zoom + zoomDelta, ZOOM_MIN, ZOOM_MAX),
pan: {
x: clamp(pan.x + xDelta, 0, 100),
y: clamp(pan.y + yDelta, 0, 100),
},
};
}
}
@@ -0,0 +1,60 @@
import { PTZMultiActionConfig } from '../../../config/types';
import { createPTZAction, createPTZDigitalAction } from '../../../utils/action';
import { PTZType, getPTZTarget, hasCameraTruePTZ } from '../../../utils/ptz';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
import { PTZAction } from './ptz';
import { PTZDigitalAction } from './ptz-digital';
export class PTZMultiAction extends FrigateCardAction<PTZMultiActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
const view = api.getViewManager().getView();
let targetID: string | null = null;
let type: PTZType | null = null;
if (this._action.target_id) {
targetID = this._action.target_id;
type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital';
} else if (view) {
const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() });
targetID = multiTarget?.targetID ?? null;
type = multiTarget?.type ?? null;
}
if (!targetID || type === null) {
return;
}
(type === 'ptz'
? this._toPTZAction(targetID)
: this._toPTZDigitalAction(targetID)
).execute(api);
}
protected _toPTZAction(targetID: string): PTZAction {
return new PTZAction(
this._context,
createPTZAction({
cardID: this._action.card_id,
cameraID: targetID,
ptzAction: this._action.ptz_action,
ptzPhase: this._action.ptz_phase,
ptzPreset: this._action.ptz_preset,
}),
this._config,
);
}
protected _toPTZDigitalAction(targetID: string): PTZDigitalAction {
return new PTZDigitalAction(
this._context,
createPTZDigitalAction({
cardID: this._action.card_id,
ptzPhase: this._action.ptz_phase,
ptzAction: this._action.ptz_action,
targetID: targetID,
}),
this._config,
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import { PTZActionConfig } from '../../../config/types';
import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz';
import { Timer } from '../../../utils/timer';
import { CardActionsAPI } from '../../types';
import {
setInProgressForThisTarget,
stopInProgressForThisTarget,
} from '../utils/action-state';
import { FrigateCardAction } from './base';
interface PTZContext {
[cameraID: string]: {
inProgressAction?: PTZAction;
};
}
declare module 'action' {
interface ActionContext {
ptz?: PTZContext;
}
}
export class PTZAction extends FrigateCardAction<PTZActionConfig> {
protected _timer = new Timer();
public async stop(): Promise<void> {
this._timer.stop();
}
public async execute(api: CardActionsAPI): Promise<void> {
const view = api.getViewManager().getView();
if (!view) {
return;
}
const ptzCameraID =
this._action.camera ??
getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() })
?.targetID ??
null;
const ptzCapabilities = ptzCameraID
? api
.getCameraManager()
.getCameraCapabilities(ptzCameraID)
?.getPTZCapabilities()
: null;
const ptzConfiguration = ptzCameraID
? api.getCameraManager().getStore().getCameraConfig(ptzCameraID)?.ptz
: null;
if (!ptzCameraID || !ptzCapabilities || !ptzConfiguration) {
return;
}
if (!this._action.ptz_action) {
if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
phase: this._action.ptz_phase,
preset: ptzCapabilities.presets[0],
});
}
return;
}
const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action);
if (
(capabilityKey &&
ptzCapabilities[capabilityKey]?.includes(
this._action.ptz_phase ? 'continuous' : 'relative',
)) ||
this._action.ptz_action === 'preset'
) {
// Scenario: Camera natively supports requested move type.
return await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
phase: this._action.ptz_phase,
preset: this._action.ptz_preset,
});
}
if (this._action.ptz_phase === 'start') {
// Scenario: Asked to start a continuous move, camera only supports relative moves natively.
stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
const singleStep = async (): Promise<void> => {
this._action.ptz_action &&
(await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
}));
// Only start the timer for the next step after this step returns.
this._timer.start(ptzConfiguration.r2c_delay_between_calls_seconds, singleStep);
};
await singleStep();
} else if (this._action.ptz_phase === 'stop') {
// Scenario: Asked to stop continuous move, camera only supports relative moves natively.
stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
} else {
// Relative move (but camera only supports continuous).
await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'start',
});
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
this._action.ptz_action &&
(await api
.getCameraManager()
.executePTZAction(ptzCameraID, this._action.ptz_action, {
preset: this._action.ptz_preset,
phase: 'stop',
}));
});
}
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class ScreenshotAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getDownloadManager().downloadScreenshot();
}
}
@@ -0,0 +1,44 @@
import { ActionContext } from 'action';
import { ActionType } from '../../../config/types';
import { arrayify } from '../../../utils/basic';
import { CardActionsAPI } from '../../types';
import { ActionFactory } from '../factory';
import { Action, AuxillaryActionConfig } from '../types';
export class ActionSet implements Action {
protected _context: ActionContext;
protected _actions: Action[] = [];
protected _factory = new ActionFactory();
protected _stopped = false;
constructor(
context: ActionContext,
actions: ActionType | ActionType[],
options?: {
config?: AuxillaryActionConfig;
cardID?: string;
},
) {
this._context = context;
for (const actionObj of arrayify(actions)) {
const action = this._factory.createAction(context, actionObj, options);
if (action) {
this._actions.push(action);
}
}
}
public async execute(api: CardActionsAPI): Promise<void> {
for (const action of this._actions) {
if (this._stopped) {
break;
}
await action.execute(api);
}
}
public async stop(): Promise<void> {
this._stopped = true;
}
}
@@ -0,0 +1,12 @@
import { SleepActionConfig } from '../../../config/types';
import { sleep } from '../../../utils/basic';
import { CardActionsAPI } from '../../types';
import { timeDeltaToSeconds } from '../utils/time-delta';
import { FrigateCardAction } from './base';
export class SleepAction extends FrigateCardAction<SleepActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(_api: CardActionsAPI): Promise<void> {
await sleep(timeDeltaToSeconds(this._action.duration));
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithoutSubstream();
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream();
}
}
@@ -0,0 +1,9 @@
import { SubstreamSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream(this._action.camera);
}
}
@@ -0,0 +1,9 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class UnmuteAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getMediaLoadedInfoManager().get()?.player?.unmute();
}
}
@@ -0,0 +1,16 @@
import { ViewActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { FrigateCardAction } from './base';
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewByParameters({
viewName: this._action.frigate_card_action,
// Note: This function needs to process (view-related) commands even when
// _view has not yet been initialized (since it may be used to set a view
// via the querystring).
cameraID: api.getViewManager().getView()?.camera,
});
}
}
+133
View File
@@ -0,0 +1,133 @@
import { ActionConfig } from '@dermotduffy/custom-card-helpers';
import { ActionContext } from 'action';
import { ActionType } from '../../config/types';
import { convertActionToCardCustomAction } from '../../utils/action';
import { CameraSelectAction } from './actions/camera-select';
import { CameraUIAction } from './actions/camera-ui';
import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand';
import { FullscreenAction } from './actions/fullscreen';
import { GenericAction } from './actions/generic';
import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player';
import { MenuToggleAction } from './actions/menu-toggle';
import { MicrophoneMuteAction } from './actions/microphone-mute';
import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
import { MuteAction } from './actions/mute';
import { PauseAction } from './actions/pause';
import { PlayAction } from './actions/play';
import { PTZAction } from './actions/ptz';
import { PTZDigitalAction } from './actions/ptz-digital';
import { PTZMultiAction } from './actions/ptz-multi';
import { ScreenshotAction } from './actions/screenshot';
import { PTZControlsAction } from './actions/ptz-controls';
import { SleepAction } from './actions/sleep';
import { SubstreamOffAction } from './actions/substream-off';
import { SubstreamOnAction } from './actions/substream-on';
import { SubstreamSelectAction } from './actions/substream-select';
import { UnmuteAction } from './actions/unmute';
import { ViewAction } from './actions/view';
import { Action, AuxillaryActionConfig } from './types';
export class ActionFactory {
public createAction(
context: ActionContext,
action: ActionType,
options?: {
config?: AuxillaryActionConfig;
cardID?: string;
},
): Action | null {
const frigateCardAction = convertActionToCardCustomAction(action);
if (action.action !== 'fire-dom-event' || !frigateCardAction) {
// * There is a slight typing (but not functional) difference between
// ActionType in this card and ActionConfig in `custom-card-helpers`. See
// `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
// reason behind this difference.
return new GenericAction(context, action as ActionConfig, options?.config);
}
if (
// Command not intended for this card (e.g. query string command).
frigateCardAction.card_id &&
frigateCardAction.card_id !== options?.cardID
) {
return null;
}
switch (frigateCardAction.frigate_card_action) {
case 'default':
return new DefaultAction(context, frigateCardAction, options?.config);
case 'clip':
case 'clips':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
case 'diagnostics':
return new ViewAction(context, frigateCardAction, options?.config);
case 'sleep':
return new SleepAction(context, frigateCardAction, options?.config);
case 'download':
return new DownloadAction(context, frigateCardAction, options?.config);
case 'camera_ui':
return new CameraUIAction(context, frigateCardAction, options?.config);
case 'expand':
return new ExpandAction(context, frigateCardAction, options?.config);
case 'fullscreen':
return new FullscreenAction(context, frigateCardAction, options?.config);
case 'menu_toggle':
// This is a rare code path: this would only be used if someone has a
// menu toggle action configured outside of the menu itself.
return new MenuToggleAction(context, frigateCardAction, options?.config);
case 'camera_select':
return new CameraSelectAction(context, frigateCardAction, options?.config);
case 'live_substream_select':
return new SubstreamSelectAction(context, frigateCardAction, options?.config);
case 'live_substream_off':
return new SubstreamOffAction(context, frigateCardAction, options?.config);
case 'live_substream_on':
return new SubstreamOnAction(context, frigateCardAction, options?.config);
case 'media_player':
return new MediaPlayerAction(context, frigateCardAction, options?.config);
case 'microphone_mute':
return new MicrophoneMuteAction(context, frigateCardAction, options?.config);
case 'microphone_unmute':
return new MicrophoneUnmuteAction(context, frigateCardAction, options?.config);
case 'mute':
return new MuteAction(context, frigateCardAction, options?.config);
case 'unmute':
return new UnmuteAction(context, frigateCardAction, options?.config);
case 'play':
return new PlayAction(context, frigateCardAction, options?.config);
case 'pause':
return new PauseAction(context, frigateCardAction, options?.config);
case 'screenshot':
return new ScreenshotAction(context, frigateCardAction, options?.config);
case 'display_mode_select':
return new DisplayModeSelectAction(context, frigateCardAction, options?.config);
case 'ptz':
return new PTZAction(context, frigateCardAction, options?.config);
case 'ptz_digital':
return new PTZDigitalAction(context, frigateCardAction, options?.config);
case 'ptz_multi':
return new PTZMultiAction(context, frigateCardAction, options?.config);
case 'ptz_controls':
return new PTZControlsAction(context, frigateCardAction, options?.config);
case 'log':
return new LogAction(context, frigateCardAction, options?.config);
}
/* istanbul ignore next: this path cannot be reached -- @preserve */
console.warn(
`Frigate card received unknown card action: ${frigateCardAction['frigate_card_action']}`,
);
/* istanbul ignore next: this path cannot be reached -- @preserve */
return null;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ActionType } from "../../config/types";
import { CardActionsAPI } from "../types";
export interface AuxillaryActionConfig {
camera_image?: string;
entity?: string;
}
export interface Action {
execute(api: CardActionsAPI): Promise<void>;
stop(): Promise<void>;
}
export interface ActionExecutionRequest {
action: ActionType[] | ActionType;
config?: AuxillaryActionConfig;
}
export interface TargetedActionContext {
[targetID: string]: {
inProgressAction?: Action;
};
}
@@ -0,0 +1,25 @@
import merge from 'lodash-es/merge';
import { Action, TargetedActionContext } from '../types';
import { ActionContext } from 'action';
export const stopInProgressForThisTarget = (
targetID: string,
context?: TargetedActionContext,
): void => {
context?.[targetID]?.inProgressAction?.stop();
};
export const setInProgressForThisTarget = (
targetID: string,
context: ActionContext,
contextKey: keyof ActionContext,
action: Action,
) => {
merge(context, {
[contextKey]: {
[targetID]: {
inProgressAction: action,
},
},
});
};
@@ -0,0 +1,38 @@
import { dispatchFrigateCardEvent } from '../../../utils/basic';
import { ActionExecutionRequest } from '../types';
export const dispatchActionExecutionRequest = (
element: HTMLElement,
request: ActionExecutionRequest,
) => {
dispatchFrigateCardEvent(element, 'action:execution-request', request);
};
export interface ActionExecutionRequestEventTarget extends EventTarget {
addEventListener(
event: 'frigate-card:action:execution-request',
listener: (
this: ActionExecutionRequestEventTarget,
ev: CustomEvent<ActionExecutionRequest>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'frigate-card:action:execution-request',
listener: (
this: ActionExecutionRequestEventTarget,
ev: CustomEvent<ActionExecutionRequest>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
@@ -0,0 +1,10 @@
import { TimeDelta } from '../../../config/types';
export const timeDeltaToSeconds = (timeDelta: TimeDelta): number => {
return (
(timeDelta.h ?? 0) * 3600 +
(timeDelta.m ?? 0) * 60 +
(timeDelta.s ?? 0) +
(timeDelta.ms ?? 0) / 1000
);
};