feat: Add 'call' support to improve 2-way audio experience (#2486)
Draws significant inspiration (and direct styling) from https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank you @Maudfer ! BREAKING CHANGE: The microphone condition previously bundled two unrelated signals — whether a two-way-audio session was connected and whether the microphone was muted. Connection state is now its own dedicated call condition, and microphone is reserved purely for mute state. Configs are upgraded automatically (the card rewrites affected conditions under overrides, elements, and automations). If you maintain config by hand, convert as follows: If you only used connected: # Before ```yaml condition: microphone connected: true ``` # After ```yaml condition: call call: true ``` If you used both connected and muted — they must be split into two conditions, since they no longer live together: # Before ```yaml condition: microphone connected: true muted: false ``` # After ```yaml condition: and conditions: - condition: call call: true - condition: microphone muted: false ```
This commit is contained in:
committed by
dermotduffy
parent
bb061a1a55
commit
abcba884e5
@@ -180,11 +180,19 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cameras that depend on a given camera.
|
||||
* @param cameraManager The camera manager.
|
||||
* Get all cameras that depend on a given camera, optionally filtered by
|
||||
* capability.
|
||||
*
|
||||
* Iteration order (guaranteed by Set insertion order): if `cameraID` itself
|
||||
* passes the capability filter (or no filter is supplied), it is the first
|
||||
* element of the returned set. Callers may rely on this ordering to pick a
|
||||
* sensible default (e.g. "prefer the parent when eligible, otherwise the
|
||||
* first matching dependency").
|
||||
*
|
||||
* @param cameraID ID of the target camera.
|
||||
* @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
|
||||
* the first item in the set is guaranteed to be the cameraID itself).
|
||||
* @param capabilitySearchKeys Optional capability filter.
|
||||
* @param options Optional search options.
|
||||
* @returns A set of dependent cameraIDs.
|
||||
*/
|
||||
public getAllDependentCameras(
|
||||
cameraID: string,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CallEndActionConfig } from '../../../config/schema/actions/custom/call-end';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class CallEndAction extends AdvancedCameraCardAction<CallEndActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
api.getCallManager().end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CallStartActionConfig } from '../../../config/schema/actions/custom/call-start';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class CallStartAction extends AdvancedCameraCardAction<CallStartActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
await api.getCallManager().start(this._action.camera, this._action.stream);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player';
|
||||
import { getStreamCameraID } from '../../../utils/substream';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { getStreamCameraID } from '../../../view/substream';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off';
|
||||
import { SubstreamViewModifier } from '../../view/modifiers/substream';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
@@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionCo
|
||||
await super.execute(api);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamOffViewModifier()],
|
||||
modifiers: [new SubstreamViewModifier()],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,48 @@
|
||||
import { CameraManager } from '../../../camera-manager/manager';
|
||||
import { SubstreamViewModifier } from '../../view/modifiers/substream';
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { View } from '../../../view/view';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await super.execute(api);
|
||||
|
||||
const view = api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamOnViewModifier(api)],
|
||||
modifiers: [
|
||||
new SubstreamViewModifier(
|
||||
this._getCycledSubstreamID(view, api.getCameraManager()),
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// The next substream in the selected camera's cycle: its `substream`
|
||||
// dependencies in order, wrapping back round. `undefined` means the camera's
|
||||
// own stream (no substream).
|
||||
private _getCycledSubstreamID(
|
||||
view: View,
|
||||
cameraManager: CameraManager,
|
||||
): string | undefined {
|
||||
if (!view.camera) {
|
||||
return undefined;
|
||||
}
|
||||
const dependencies = [
|
||||
...cameraManager.getStore().getAllDependentCameras(view.camera, 'substream'),
|
||||
];
|
||||
if (dependencies.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const current = view.context?.live?.overrides?.get(view.camera) ?? view.camera;
|
||||
const currentIndex = dependencies.indexOf(current);
|
||||
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||
// Index 0 is the camera itself, i.e. no substream.
|
||||
return dependencies[nextIndex] === view.camera ? undefined : dependencies[nextIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SubstreamSelectActionConfig } from '../../../config/schema/actions/custom/substream-select';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select';
|
||||
import { SubstreamViewModifier } from '../../view/modifiers/substream';
|
||||
import { AdvancedCameraCardAction } from './base';
|
||||
|
||||
export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSelectActionConfig> {
|
||||
@@ -8,7 +8,7 @@ export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSel
|
||||
await super.execute(api);
|
||||
|
||||
api.getViewManager().setViewByParameters({
|
||||
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
|
||||
modifiers: [new SubstreamViewModifier(this._action.camera)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ActionContext } from 'action';
|
||||
import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal';
|
||||
import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types';
|
||||
import { isAdvancedCameraCardCustomAction } from '../../utils/action';
|
||||
import { CallEndAction } from './actions/call-end';
|
||||
import { CallServiceAction } from './actions/call-service';
|
||||
import { CallStartAction } from './actions/call-start';
|
||||
import { CameraSelectAction } from './actions/camera-select';
|
||||
import { CameraUIAction } from './actions/camera-ui';
|
||||
import { CustomAction } from './actions/custom';
|
||||
@@ -122,6 +124,10 @@ export class ActionFactory {
|
||||
return new InfoAction(context, action, options?.config);
|
||||
case 'menu_toggle':
|
||||
return new MenuToggleAction(context, action, options?.config);
|
||||
case 'call_start':
|
||||
return new CallStartAction(context, action, options?.config);
|
||||
case 'call_end':
|
||||
return new CallEndAction(context, action, options?.config);
|
||||
case 'camera_select':
|
||||
return new CameraSelectAction(context, action, options?.config);
|
||||
case 'live_substream_select':
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { createNotificationFromText } from '../../components-lib/notification/factory';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { View } from '../../view/view';
|
||||
import { CardCallAPI } from '../types';
|
||||
import { SubstreamViewModifier } from '../view/modifiers/substream';
|
||||
import { CallSession } from './types';
|
||||
|
||||
export class CallManager {
|
||||
private _api: CardCallAPI;
|
||||
private _call: CallSession | null = null;
|
||||
|
||||
constructor(api: CardCallAPI) {
|
||||
this._api = api;
|
||||
|
||||
// A call runs on the live view of a specific camera. Observe the
|
||||
// condition state so the call can be ended if the view, camera or engaged
|
||||
// substream moves off what the call started on.
|
||||
this._api.getConditionStateManager().addListener(this._handleConditionStateChange);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Readers
|
||||
// =========================================================================
|
||||
|
||||
public isActive(): boolean {
|
||||
return !!this._call;
|
||||
}
|
||||
|
||||
public getCall(): CallSession | null {
|
||||
return this._call;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
public async start(cameraID?: string, streamID?: string): Promise<void> {
|
||||
const view = this._api.getViewManager().getView();
|
||||
|
||||
const parentID = cameraID ?? view?.camera;
|
||||
if (!view || !parentID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getCameraIDsWithCapability('live')
|
||||
.has(parentID)
|
||||
) {
|
||||
this._notifyError('error.call_invalid_target');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetID = streamID
|
||||
? this._validateStream(parentID, streamID)
|
||||
: this._pickDefaultTarget(view, parentID);
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `callCameraID` is the substream carrying the call audio -- absent when
|
||||
// the call runs on the parent camera itself.
|
||||
const callCameraID = targetID === parentID ? undefined : targetID;
|
||||
|
||||
const existingCall = this._call;
|
||||
if (
|
||||
existingCall &&
|
||||
existingCall.cameraID === parentID &&
|
||||
existingCall.callCameraID === callCameraID
|
||||
) {
|
||||
// This exact call (same parent camera and stream) is already running; a
|
||||
// repeat request must not disrupt it.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._microphonePreflight()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this._connectMicrophone())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the previous view so it can be restored later. A call superseding
|
||||
// another inherits the earlier call's previous view -- the user never left
|
||||
// the call. `queryResults` are dropped (re-fetched fresh on restore);
|
||||
// `context` is deep-cloned so the call engaging its own substream below
|
||||
// cannot mutate the snapshot.
|
||||
const previousView = existingCall
|
||||
? existingCall.previousView
|
||||
: view.evolve({ queryResults: null, context: cloneDeep(view.context) });
|
||||
|
||||
const needsNavigation = !view.is('live') || view.camera !== parentID;
|
||||
|
||||
// Any other call in progress is superseded. Ended here -- after the
|
||||
// preflight passes -- so a failed preflight leaves the existing call
|
||||
// intact.
|
||||
if (existingCall) {
|
||||
this._end(false);
|
||||
}
|
||||
|
||||
this._call = {
|
||||
cameraID: parentID,
|
||||
...(callCameraID && { callCameraID }),
|
||||
previousView,
|
||||
};
|
||||
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
...(needsNavigation && {
|
||||
params: { view: 'live', camera: parentID },
|
||||
}),
|
||||
modifiers: [new SubstreamViewModifier(callCameraID, parentID)],
|
||||
force: true,
|
||||
});
|
||||
this._api.getConditionStateManager().setState({ call: true });
|
||||
}
|
||||
|
||||
// Ends the call and returns to the view that was showing before
|
||||
// `call_start` -- the user-facing `call_end`.
|
||||
public end(): void {
|
||||
this._end(true);
|
||||
}
|
||||
|
||||
// `restoreView` navigates back to the pre-call view -- the symmetric
|
||||
// counterpart of `call_start`'s navigation -- for an explicit `call_end`. It
|
||||
// is `false` for auto-ends (navigating away, camera/substream change), where
|
||||
// the user has already chosen a destination and the pre-call view is
|
||||
// deliberately not reinstated; only the manager's own auto-end paths pass it.
|
||||
private _end(restoreView: boolean): void {
|
||||
if (!this._call) {
|
||||
return;
|
||||
}
|
||||
const call = this._call;
|
||||
const previousView = call.previousView;
|
||||
|
||||
// Clear the session first: ending the call dispatches a view change, and
|
||||
// the resulting condition-state change must not see this (now-ending) call
|
||||
// and recurse.
|
||||
this._call = null;
|
||||
|
||||
const viewManager = this._api.getViewManager();
|
||||
|
||||
// Navigate back only on an explicit end, and only when the call actually
|
||||
// moved away from where the user was (a call started from its own live
|
||||
// view has nowhere to return). The previous view's query is re-executed
|
||||
// so results are fresh.
|
||||
if (
|
||||
restoreView &&
|
||||
(previousView.view !== 'live' || previousView.camera !== call.cameraID)
|
||||
) {
|
||||
viewManager.setViewByParametersWithExistingQuery({
|
||||
baseView: previousView,
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
// Otherwise stay where we are and just undo the call's substream change
|
||||
// on its own camera, reading the pre-call value.
|
||||
const previousStream = getStreamCameraID(previousView, call.cameraID);
|
||||
viewManager.setViewByParameters({
|
||||
modifiers: [
|
||||
new SubstreamViewModifier(
|
||||
previousStream && previousStream !== call.cameraID
|
||||
? previousStream
|
||||
: undefined,
|
||||
call.cameraID,
|
||||
),
|
||||
],
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
this._api.getConditionStateManager().setState({ call: false });
|
||||
}
|
||||
|
||||
// End the call once it can no longer be conducted from where it started: the
|
||||
// view leaves `live` (the call overlay exists only there, so the call would
|
||||
// otherwise be stranded with no controls), the selected camera changes, or
|
||||
// the engaged substream moves off the call's audio source. Covers navigation
|
||||
// and `live_substream_*` actions taken while `live.controls.call.lock` is
|
||||
// disabled, as well as any forced view change.
|
||||
private _handleConditionStateChange = (stateChange: ConditionStateChange): void => {
|
||||
if (
|
||||
this._call &&
|
||||
(stateChange.new.view !== 'live' ||
|
||||
stateChange.new.camera !== this._call.cameraID ||
|
||||
stateChange.new.substreamID !== this._call.callCameraID)
|
||||
) {
|
||||
this._end(false);
|
||||
}
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private _notifyError(messageKey: string): void {
|
||||
this._api.getNotificationManager().setNotification(
|
||||
createNotificationFromText(localize(messageKey), {
|
||||
heading: { text: localize('error.call_unavailable_heading') },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns `true` to proceed, `false` to abort (with a notification already
|
||||
// surfaced).
|
||||
private _microphonePreflight(): boolean {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
|
||||
if (!microphoneManager.isSupported()) {
|
||||
this._notifyError('error.call_microphone_unsupported');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (microphoneManager.isForbidden()) {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _connectMicrophone(): Promise<boolean> {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
if (microphoneManager.isConnected()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await microphoneManager.connect();
|
||||
return true;
|
||||
} catch {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _hasCallCapability(cameraID: string): boolean {
|
||||
return !!this._api
|
||||
.getCameraManager()
|
||||
.getCameraCapabilities(cameraID)
|
||||
?.has('2-way-audio');
|
||||
}
|
||||
|
||||
// Validate an explicitly-requested call stream: it must be `cameraID` itself
|
||||
// or one of its 2-way-audio dependencies.
|
||||
private _validateStream(cameraID: string, streamID: string): string | null {
|
||||
const eligibleCameraIDs = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(cameraID, '2-way-audio');
|
||||
if (!eligibleCameraIDs.has(streamID)) {
|
||||
this._notifyError('error.call_invalid_target');
|
||||
return null;
|
||||
}
|
||||
return streamID;
|
||||
}
|
||||
|
||||
// Pick the default call target. Prefer the currently-engaged stream when
|
||||
// it's call-capable (keeps the user's substream selection intact). Else
|
||||
// fall back to the parent itself (if call-capable) or the first eligible
|
||||
// dependency. Returns null + notification if neither path finds a target.
|
||||
private _pickDefaultTarget(view: View, parentID: string): string | null {
|
||||
const currentStream = getStreamCameraID(view, parentID);
|
||||
if (currentStream && this._hasCallCapability(currentStream)) {
|
||||
return currentStream;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(parentID, '2-way-audio'),
|
||||
];
|
||||
if (!candidates.length) {
|
||||
this._notifyError('error.call_no_two_way_audio');
|
||||
return null;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { View } from '../../view/view';
|
||||
|
||||
export interface CallSession {
|
||||
// The camera that owns the call.
|
||||
cameraID: string;
|
||||
|
||||
// The substream carrying the 2-way audio: a 2-way-audio-capable
|
||||
// substream/dependency of `cameraID`. Absent when the call runs on
|
||||
// `cameraID`'s own stream.
|
||||
callCameraID?: string;
|
||||
|
||||
// The view from before the call started: a clone with `queryResults` dropped.
|
||||
// Used to undo the call when it ends.
|
||||
previousView: View;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import { LovelaceCardEditor } from '../ha/types';
|
||||
import { ActionsManager } from './actions/actions-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CallManager } from './call/manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
import {
|
||||
CardElementManager,
|
||||
@@ -26,6 +27,8 @@ import { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import { HASSManager } from './hass/hass-manager';
|
||||
import { InitializationManager } from './initialization-manager';
|
||||
import { InteractionManager } from './interaction-manager';
|
||||
import { createIssueManager } from './issues/factory';
|
||||
import { IssueManager } from './issues/issue-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { LockManager } from './lock/manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
@@ -33,8 +36,6 @@ import { MediaPlayerManager } from './media-player-manager';
|
||||
import { MicrophoneManager } from './microphone-manager';
|
||||
import { NotificationManager } from './notification-manager';
|
||||
import { PIPManager } from './pip-manager';
|
||||
import { createIssueManager } from './issues/factory';
|
||||
import { IssueManager } from './issues/issue-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
CardHASSAPI,
|
||||
CardInitializerAPI,
|
||||
CardInteractionAPI,
|
||||
CardIssueManagerAPI,
|
||||
CardKeyboardStateAPI,
|
||||
CardLockAPI,
|
||||
CardMediaLoadedAPI,
|
||||
@@ -62,7 +64,6 @@ import {
|
||||
CardMicrophoneAPI,
|
||||
CardNotificationAPI,
|
||||
CardPIPAPI,
|
||||
CardIssueManagerAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -112,6 +113,7 @@ export class CardController
|
||||
|
||||
private _actionsManager = new ActionsManager(this, new TemplateRenderer());
|
||||
private _automationsManager = new AutomationsManager(this);
|
||||
private _callManager = new CallManager(this);
|
||||
private _cameraManager = new CameraManager(this);
|
||||
private _cameraURLManager = new CameraURLManager(this);
|
||||
private _cardElementManager: CardElementManager;
|
||||
@@ -166,6 +168,10 @@ export class CardController
|
||||
return this._automationsManager;
|
||||
}
|
||||
|
||||
public getCallManager(): CallManager {
|
||||
return this._callManager;
|
||||
}
|
||||
|
||||
public getCameraManager(): CameraManager {
|
||||
return this._cameraManager;
|
||||
}
|
||||
|
||||
+12
-9
@@ -4,12 +4,16 @@ import { isAdvancedCameraCardCustomAction } from '../../utils/action';
|
||||
import { CardLockAPI } from '../types';
|
||||
import type { LockPolicy } from './types';
|
||||
|
||||
// Action that disrupt a hot-microphone session. Covers two categories:
|
||||
// Actions that would disrupt an active call. Two categories:
|
||||
// - Major media changes (see `ViewManager.hasMajorMediaChange`): view,
|
||||
// camera, and substream changes.
|
||||
// - Stream-stopping / re-init actions: pause, reload, and casting (which
|
||||
// rehosts the stream to a media player).
|
||||
const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
||||
//
|
||||
// `call_start` is intentionally absent — it's the entry into the lock.
|
||||
// `call_end` is also absent — it dispatches via `setViewByParameters({ force:
|
||||
// true })` to bypass the lock, so listing it here would be redundant.
|
||||
const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
||||
// View / camera / substream changes.
|
||||
...VIEWS_USER_SPECIFIED,
|
||||
'camera_select',
|
||||
@@ -31,7 +35,7 @@ const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
|
||||
'media_player',
|
||||
]);
|
||||
|
||||
export class MicrophoneLockPolicy implements LockPolicy {
|
||||
export class CallLockPolicy implements LockPolicy {
|
||||
private _api: CardLockAPI;
|
||||
|
||||
constructor(api: CardLockAPI) {
|
||||
@@ -39,17 +43,16 @@ export class MicrophoneLockPolicy implements LockPolicy {
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this._api.getMicrophoneManager().isLocking();
|
||||
if (!this._api.getConfigManager().getConfig()?.live.controls.call.lock) {
|
||||
return false;
|
||||
}
|
||||
return this._api.getCallManager().isActive();
|
||||
}
|
||||
|
||||
public shouldBlockAction(action: ActionConfig): boolean {
|
||||
return this._isMicrophoneSessionDisruptiveAction(action);
|
||||
}
|
||||
|
||||
private _isMicrophoneSessionDisruptiveAction(action: ActionConfig): boolean {
|
||||
return (
|
||||
isAdvancedCameraCardCustomAction(action) &&
|
||||
MICROPHONE_SESSION_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action)
|
||||
CALL_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionConfig, Actions } from '../../config/schema/actions/types';
|
||||
import { arrayify } from '../../utils/basic';
|
||||
import { CardLockAPI } from '../types';
|
||||
import { MicrophoneLockPolicy } from './microphone-policy';
|
||||
import { CallLockPolicy } from './call-policy';
|
||||
import type { LockManagerEpoch, LockPolicy } from './types';
|
||||
|
||||
export class LockManager {
|
||||
@@ -9,7 +9,7 @@ export class LockManager {
|
||||
private _epoch: LockManagerEpoch | null = null;
|
||||
|
||||
constructor(api: CardLockAPI) {
|
||||
this._policies = [new MicrophoneLockPolicy(api)];
|
||||
this._policies = [new CallLockPolicy(api)];
|
||||
}
|
||||
|
||||
public isLocked(): boolean {
|
||||
|
||||
@@ -119,13 +119,6 @@ export class MicrophoneManager {
|
||||
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
|
||||
}
|
||||
|
||||
public isLocking(): boolean {
|
||||
// The user-facing rationale: while the microphone is hot (e.g. mid 2-way
|
||||
// audio), prevent accidental swipes, pauses, view changes.
|
||||
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
|
||||
return !!microphoneConfig?.lock && !this.isMuted();
|
||||
}
|
||||
|
||||
private _setDesiredMuteOnStream(): void {
|
||||
this._stream?.getTracks().forEach((track) => {
|
||||
track.enabled = !this._desireMute;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createViewAction,
|
||||
} from '../utils/action.js';
|
||||
import { CardQueryStringAPI } from './types';
|
||||
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
|
||||
import { SubstreamViewModifier } from './view/modifiers/substream';
|
||||
import { ViewParametersUserSpecified } from './view/types.js';
|
||||
|
||||
interface QueryStringViewIntent {
|
||||
@@ -50,7 +50,7 @@ export class QueryStringManager {
|
||||
camera: intent.view.camera,
|
||||
},
|
||||
...(intent.view.substream && {
|
||||
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
|
||||
modifiers: [new SubstreamViewModifier(intent.view.substream)],
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
@@ -60,7 +60,7 @@ export class QueryStringManager {
|
||||
...(intent.view.camera && { camera: intent.view.camera }),
|
||||
},
|
||||
...(intent.view.substream && {
|
||||
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
|
||||
modifiers: [new SubstreamViewModifier(intent.view.substream)],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import type { EffectsManagerInterface } from '../types';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CallManager } from './call/manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
import type { CardElementManager } from './card-element-manager';
|
||||
import type { ConfigManager } from './config/config-manager';
|
||||
@@ -41,6 +42,7 @@ import type { ViewManager } from './view/view-manager';
|
||||
|
||||
export interface CardActionsAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCallManager(): CallManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getCameraURLManager(): CameraURLManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
@@ -75,6 +77,14 @@ export interface CardAutomationsAPI {
|
||||
getIssueManager(): IssueManager;
|
||||
}
|
||||
|
||||
export interface CardCallAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardCameraAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
@@ -237,7 +247,9 @@ export interface CardKeyboardStateAPI {
|
||||
}
|
||||
|
||||
export interface CardLockAPI {
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getCallManager(): CallManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardMediaLoadedAPI {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { removeSubstream } from '../../../utils/substream';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SubstreamOffViewModifier implements ViewModifier {
|
||||
public modify(view: View): void {
|
||||
removeSubstream(view);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { CameraManager } from '../../../camera-manager/manager';
|
||||
import { getStreamCameraID, setSubstream } from '../../../utils/substream';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
interface SubstreamOnViewModifierAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
}
|
||||
|
||||
export class SubstreamOnViewModifier implements ViewModifier {
|
||||
private _api: SubstreamOnViewModifierAPI;
|
||||
|
||||
constructor(api: SubstreamOnViewModifierAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = [
|
||||
...this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(view.camera, 'substream'),
|
||||
];
|
||||
|
||||
if (dependencies.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOverride = getStreamCameraID(view);
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached, as there is a
|
||||
view.camera guard at the start of this method and getStreamCameraID will
|
||||
always return non-null as long as camera is present -- @preserve */
|
||||
if (!currentOverride) {
|
||||
return;
|
||||
}
|
||||
const currentIndex = dependencies.indexOf(currentOverride);
|
||||
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
|
||||
|
||||
setSubstream(view, dependencies[newIndex]);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { setSubstream } from '../../../utils/substream';
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
export class SubstreamSelectViewModifier implements ViewModifier {
|
||||
private _substreamID: string;
|
||||
|
||||
constructor(substreamID: string) {
|
||||
this._substreamID = substreamID;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
setSubstream(view, this._substreamID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { View } from '../../../view/view';
|
||||
import { ViewModifier } from '../types';
|
||||
|
||||
// The single write path for the camera-keyed `live.overrides` map (the read
|
||||
// path being `getStreamCameraID` in `view/substream`): sets a camera's
|
||||
// substream override, or clears it when `substreamID` is absent so the
|
||||
// camera's own stream is used. `cameraID` defaults to the selected camera.
|
||||
export class SubstreamViewModifier implements ViewModifier {
|
||||
private _substreamID?: string;
|
||||
private _cameraID?: string;
|
||||
|
||||
constructor(substreamID?: string, cameraID?: string) {
|
||||
this._substreamID = substreamID;
|
||||
this._cameraID = cameraID;
|
||||
}
|
||||
|
||||
public modify(view: View): void {
|
||||
const cameraID = this._cameraID ?? view.camera;
|
||||
if (!cameraID) {
|
||||
return;
|
||||
}
|
||||
if (!this._substreamID) {
|
||||
view.context?.live?.overrides?.delete(cameraID);
|
||||
return;
|
||||
}
|
||||
const overrides = view.context?.live?.overrides ?? new Map<string, string>();
|
||||
overrides.set(cameraID, this._substreamID);
|
||||
view.mergeInContext({ live: { overrides } });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { View } from '../../view/view';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { getViewTargetID } from '../../view/target-id';
|
||||
import { View } from '../../view/view';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
@@ -91,12 +91,25 @@ export class ViewManager implements ViewManagerInterface {
|
||||
|
||||
setViewByParametersWithExistingQuery = async (
|
||||
options?: ViewFactoryOptions,
|
||||
): Promise<void> =>
|
||||
): Promise<void> => {
|
||||
// Default the query to the base view's own, so a bare `baseView`
|
||||
// re-executes its query (`_setViewThenModifyAsync` otherwise nulls it).
|
||||
// Only an omitted query falls back; an explicit query -- including `null`
|
||||
// to clear it -- is left as the caller specified.
|
||||
const baseView = options?.baseView ?? this._view;
|
||||
const explicitQuery = options?.params?.query;
|
||||
await this._setViewThenModifyAsync(
|
||||
this._viewFactory.getViewByParameters.bind(this._viewFactory),
|
||||
this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor),
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
params: {
|
||||
...options?.params,
|
||||
query: explicitQuery !== undefined ? explicitQuery : baseView?.query ?? null,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
private _setViewGeneric(
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
@@ -374,11 +387,13 @@ export class ViewManager implements ViewManagerInterface {
|
||||
|
||||
this._api.getStyleManager().setExpandedMode();
|
||||
|
||||
const stream = view ? getStreamCameraID(view) : null;
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
view: view?.view,
|
||||
camera: view?.camera ?? undefined,
|
||||
displayMode: view?.displayMode ?? undefined,
|
||||
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
|
||||
substreamID: stream && stream !== view?.camera ? stream : undefined,
|
||||
});
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'web-dialog';
|
||||
import { actionHandler } from './action-handler-directive.js';
|
||||
import { CardController } from './card-controller/controller';
|
||||
import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js';
|
||||
import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js';
|
||||
import { MenuButtonController } from './components-lib/menu-button-controller';
|
||||
import './components/effects/effects';
|
||||
import './components/elements.js';
|
||||
@@ -243,6 +244,10 @@ class AdvancedCameraCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected _getAutoHideState(): AutoHideState {
|
||||
return resolveAutoHideState(this._controller.getCallManager().isActive());
|
||||
}
|
||||
|
||||
protected _renderMenu(slot?: string): TemplateResult | void {
|
||||
const view = this._controller.getViewManager().getView();
|
||||
if (!this._hass || !this._config) {
|
||||
@@ -261,6 +266,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
this._controller.getCameraManager(),
|
||||
this._controller.getFoldersManager(),
|
||||
{
|
||||
callManager: this._controller.getCallManager(),
|
||||
currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
|
||||
fullscreenManager: this._controller.getFullscreenManager(),
|
||||
inExpandedMode: this._controller.getExpandManager().isExpanded(),
|
||||
@@ -273,6 +279,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
},
|
||||
)}
|
||||
.entityRegistryManager=${this._controller.getEntityRegistryManager()}
|
||||
.autoHideState=${this._getAutoHideState()}
|
||||
></advanced-camera-card-menu>
|
||||
`;
|
||||
}
|
||||
@@ -305,6 +312,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
.getIssueDescriptions(),
|
||||
})}
|
||||
.config=${this._config.status_bar}
|
||||
.autoHideState=${this._getAutoHideState()}
|
||||
></advanced-camera-card-status-bar>
|
||||
`;
|
||||
}
|
||||
@@ -427,6 +435,7 @@ class AdvancedCameraCard extends LitElement {
|
||||
.hide=${!!fullCardIssue}
|
||||
.microphoneManager=${this._controller.getMicrophoneManager()}
|
||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||
.call=${this._controller.getCallManager().getCall() ?? undefined}
|
||||
.locked=${this._controller.getLockManager().isLocked()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AutoHideCondition } from '../config/schema/common/auto-hide';
|
||||
import { isBeingCasted } from '../utils/casting';
|
||||
|
||||
// Whether each auto-hide condition is currently active.
|
||||
export interface AutoHideState {
|
||||
call: boolean;
|
||||
casting: boolean;
|
||||
}
|
||||
|
||||
// Single constructor for the auto-hide state. `casting` is read from the
|
||||
// environment; `call` is the only context-specific input, supplied by the
|
||||
// caller (absent when no call notion applies, e.g. the media viewer).
|
||||
export const resolveAutoHideState = (callActive = false): AutoHideState => ({
|
||||
call: callActive,
|
||||
casting: isBeingCasted(),
|
||||
});
|
||||
|
||||
export const isAutoHidden = (
|
||||
autoHide: readonly AutoHideCondition[],
|
||||
state: AutoHideState,
|
||||
): boolean => autoHide.some((condition) => state[condition]);
|
||||
@@ -26,6 +26,7 @@ interface MicrophoneActionsControllerOptions {
|
||||
export class MicrophoneActionsController {
|
||||
private _options: MicrophoneActionsControllerOptions | null = null;
|
||||
private _selectedCamera: string | null = null;
|
||||
private _callActive = false;
|
||||
private _visibilityObserver: VisibilityObserver;
|
||||
|
||||
constructor() {
|
||||
@@ -38,6 +39,27 @@ export class MicrophoneActionsController {
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the controller of the call-active state, acting only on a genuine
|
||||
* transition. The initial state is treated as inactive, so a first-ever
|
||||
* `true` counts -- the call rules apply even when the live view first
|
||||
* appears during an active call.
|
||||
*
|
||||
* Call start unmutes the microphone only if the user opted into
|
||||
* `microphone.auto_unmute: ['call']`.
|
||||
*/
|
||||
public setCallActive(active: boolean): void {
|
||||
if (active === this._callActive) {
|
||||
return;
|
||||
}
|
||||
this._callActive = active;
|
||||
if (active) {
|
||||
this._unmuteIfConfigured('call');
|
||||
} else {
|
||||
this._muteIfConfigured('call');
|
||||
}
|
||||
}
|
||||
|
||||
public setRoot(root: HTMLElement): void {
|
||||
this._visibilityObserver.setRoot(root);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface MediaActionsControllerOptions {
|
||||
autoPauseConditions?: readonly AutoPauseCondition[];
|
||||
autoMuteConditions?: readonly AutoMuteCondition[];
|
||||
|
||||
microphoneState?: MicrophoneState;
|
||||
microphoneMuteSeconds?: number;
|
||||
}
|
||||
|
||||
@@ -40,6 +39,16 @@ export class MediaActionsController {
|
||||
private _microphoneMuteTimer = new Timer();
|
||||
private _root: RenderRoot | null = null;
|
||||
|
||||
// Audio-related state fed in via dedicated setters (not `setOptions`, which
|
||||
// is pure configuration).
|
||||
private _microphoneState?: MicrophoneState;
|
||||
private _callActive = false;
|
||||
|
||||
// Deferred because the media player is not always ready when a call starts:
|
||||
// the call may start from another view, or engage a substream that is still
|
||||
// loading. Applied by `_applyPendingCallStartAction`.
|
||||
private _pendingCallStartAction = false;
|
||||
|
||||
private _eventListeners = new Map<HTMLElement, () => void>();
|
||||
private _children: MediaPlayerElement[] = [];
|
||||
private _target: MediaActionsTarget | null = null;
|
||||
@@ -53,16 +62,34 @@ export class MediaActionsController {
|
||||
}
|
||||
|
||||
public setOptions(options: MediaActionsControllerOptions): void {
|
||||
if (this._options?.microphoneState !== options.microphoneState) {
|
||||
this._microphoneStateChangeHandler(
|
||||
this._options?.microphoneState,
|
||||
options.microphoneState,
|
||||
);
|
||||
}
|
||||
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
public setMicrophoneState(state: MicrophoneState): void {
|
||||
const previous = this._microphoneState;
|
||||
this._microphoneState = state;
|
||||
this._microphoneStateChangeHandler(previous, state);
|
||||
}
|
||||
|
||||
// Audio-out auto-mute/unmute driven by the call lifecycle: unmute on call
|
||||
// start (hear the caller), mute on call end. Acts only on a genuine
|
||||
// transition. The first-ever `true` counts as a transition: a carousel that
|
||||
// loads while a call is already active (e.g. `call_start` dispatched from a
|
||||
// non-live view) must still unmute.
|
||||
public setCallActive(active: boolean): void {
|
||||
if (active === this._callActive) {
|
||||
return;
|
||||
}
|
||||
this._callActive = active;
|
||||
if (active) {
|
||||
this._pendingCallStartAction = true;
|
||||
this._applyPendingCallStartAction();
|
||||
} else {
|
||||
this._pendingCallStartAction = false;
|
||||
this._muteTargetIfConfigured('call');
|
||||
}
|
||||
}
|
||||
|
||||
public hasRoot(): boolean {
|
||||
return !!this._root;
|
||||
}
|
||||
@@ -94,6 +121,9 @@ export class MediaActionsController {
|
||||
index,
|
||||
};
|
||||
|
||||
// A call may have started before this target existed; honor it now.
|
||||
await this._applyPendingCallStartAction();
|
||||
|
||||
if (selected) {
|
||||
await this._unmuteTargetIfConfigured('selected');
|
||||
await this._playTargetIfConfigured('selected');
|
||||
@@ -132,6 +162,30 @@ export class MediaActionsController {
|
||||
await (await this._children[index]?.getMediaPlayerController())?.unmute();
|
||||
}
|
||||
|
||||
// The call-start action is currently a single unmute (hear the caller),
|
||||
// applied once the media player is ready. The call-end mute is not deferred
|
||||
// here: a mute that misses a not-yet-ready element is harmless, since
|
||||
// elements start muted.
|
||||
private async _applyPendingCallStartAction(): Promise<void> {
|
||||
if (
|
||||
!this._pendingCallStartAction ||
|
||||
this._target === null ||
|
||||
!this._options?.autoUnmuteConditions?.includes('call')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller =
|
||||
await this._children[this._target.index]?.getMediaPlayerController();
|
||||
if (!controller) {
|
||||
// Media not ready yet -- retried from `setTarget` / `_mediaLoadedHandler`.
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingCallStartAction = false;
|
||||
await controller.unmute();
|
||||
}
|
||||
|
||||
private async _pauseAllIfConfigured(condition: AutoPauseCondition): Promise<void> {
|
||||
if (this._options?.autoPauseConditions?.includes(condition)) {
|
||||
for (const index of this._children.keys()) {
|
||||
@@ -190,6 +244,10 @@ export class MediaActionsController {
|
||||
// media load.
|
||||
const condition = this._target.selected ? 'selected' : 'visible';
|
||||
await this._unmuteTargetIfConfigured(condition);
|
||||
|
||||
// The media element is ready now; apply any call-start action that was
|
||||
// deferred because it was not.
|
||||
await this._applyPendingCallStartAction();
|
||||
await this._playTargetIfConfigured(condition);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StyleInfo } from 'lit/directives/style-map';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { CallManager } from '../card-controller/call/manager';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager';
|
||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||
@@ -17,6 +18,8 @@ import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import {
|
||||
createCallEndAction,
|
||||
createCallStartAction,
|
||||
createCameraAction,
|
||||
createDisplayModeAction,
|
||||
createGeneralAction,
|
||||
@@ -30,8 +33,8 @@ import {
|
||||
import { arrayify, isTruthy } from '../utils/basic';
|
||||
import { isBeingCasted } from '../utils/casting';
|
||||
import { getPTZTarget } from '../utils/ptz';
|
||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { getStreamCameraID, hasSubstream } from '../view/substream';
|
||||
import { resolveViewName } from '../view/utils/resolve-default';
|
||||
import { View } from '../view/view';
|
||||
import {
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
} from '../view/view-support';
|
||||
|
||||
export interface MenuButtonControllerOptions {
|
||||
callManager?: CallManager | null;
|
||||
currentMediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
showCameraUIButton?: boolean;
|
||||
fullscreenManager?: FullscreenManager | null;
|
||||
@@ -94,11 +98,13 @@ export class MenuButtonController {
|
||||
this._getInfoButton(config, cameraManager, options?.view),
|
||||
this._getSetReviewButton(config, options?.view),
|
||||
this._getCameraUIButton(config, options?.showCameraUIButton),
|
||||
this._getCallButton(config, cameraManager, options?.callManager, options?.view),
|
||||
this._getMicrophoneButton(
|
||||
config,
|
||||
cameraManager,
|
||||
options?.view,
|
||||
options?.microphoneManager,
|
||||
options?.callManager,
|
||||
),
|
||||
this._getExpandButton(config, options?.inExpandedMode),
|
||||
this._getFullscreenButton(config, options?.fullscreenManager),
|
||||
@@ -480,17 +486,91 @@ export class MenuButtonController {
|
||||
: null;
|
||||
}
|
||||
|
||||
private _getCallButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
callManager?: CallManager | null,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
if (!view?.camera || !view.is('live')) {
|
||||
return null;
|
||||
}
|
||||
const cameraID = view.camera;
|
||||
|
||||
// The call targets: the selected camera and/or any 2-way-audio-capable
|
||||
// dependency.
|
||||
const targets = [
|
||||
...cameraManager.getStore().getAllDependentCameras(cameraID, '2-way-audio'),
|
||||
];
|
||||
if (!targets.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In a call: a single hang-up button, regardless of target count.
|
||||
if (callManager?.isActive()) {
|
||||
return {
|
||||
icon: 'mdi:phone-hangup',
|
||||
title: localize('config.live.controls.call.end'),
|
||||
style: this._getEmphasizedStyle(true),
|
||||
...config.menu.buttons.call,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
tap_action: createCallEndAction(),
|
||||
};
|
||||
}
|
||||
|
||||
// Idle, single target: a plain button (`call_start` resolves the default).
|
||||
if (targets.length === 1) {
|
||||
return {
|
||||
icon: 'mdi:phone',
|
||||
title: localize('config.live.controls.call.start'),
|
||||
...config.menu.buttons.call,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
tap_action: createCallStartAction(),
|
||||
};
|
||||
}
|
||||
|
||||
// Idle, multiple targets: a submenu, one entry per stream.
|
||||
const menuItems = targets.map((streamID) => {
|
||||
const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined;
|
||||
return {
|
||||
enabled: true,
|
||||
icon: metadata?.icon.icon,
|
||||
entity: metadata?.icon.entity,
|
||||
state_color: true,
|
||||
title: metadata?.title,
|
||||
tap_action: createCallStartAction(
|
||||
cameraID,
|
||||
streamID === cameraID ? undefined : streamID,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
icon: 'mdi:phone',
|
||||
title: localize('config.live.controls.call.start'),
|
||||
...config.menu.buttons.call,
|
||||
type: 'custom:advanced-camera-card-menu-submenu',
|
||||
items: menuItems,
|
||||
};
|
||||
}
|
||||
|
||||
private _getMicrophoneButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
view?: View | null,
|
||||
microphoneManager?: MicrophoneManager | null,
|
||||
callManager?: CallManager | null,
|
||||
): MenuItem | null {
|
||||
const streamCameraID = view ? getStreamCameraID(view) : null;
|
||||
if (!streamCameraID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The microphone only transmits during an active call.
|
||||
if (!callManager?.isActive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const capabilities = cameraManager.getCameraCapabilities(streamCameraID);
|
||||
|
||||
if (microphoneManager && capabilities?.has('2-way-audio')) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MenuConfig } from '../config/schema/menu.js';
|
||||
import type { Interaction } from '../types.js';
|
||||
import { getActionConfigGivenAction } from '../utils/action';
|
||||
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
|
||||
import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js';
|
||||
|
||||
export class MenuController {
|
||||
private _host: LitElement;
|
||||
@@ -17,6 +18,7 @@ export class MenuController {
|
||||
private _buttons: MenuItem[] = [];
|
||||
private _expanded = false;
|
||||
private _lockManagerEpoch?: LockManagerEpoch;
|
||||
private _autoHideState: AutoHideState | null = null;
|
||||
|
||||
constructor(host: LitElement) {
|
||||
this._host = host;
|
||||
@@ -74,6 +76,21 @@ export class MenuController {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public setAutoHideState(state: AutoHideState): void {
|
||||
this._autoHideState = state;
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public shouldRender(): boolean {
|
||||
if (!this._config || this._config.style === 'none') {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
this._autoHideState &&
|
||||
evaluateAutoHidden(this._config.auto_hide, this._autoHideState)
|
||||
);
|
||||
}
|
||||
|
||||
public isExpanded(): boolean {
|
||||
return this._expanded;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StatusBarConfig } from '../config/schema/status-bar';
|
||||
import { getActionConfigGivenAction } from '../utils/action';
|
||||
import { arrayify, setOrRemoveAttribute } from '../utils/basic';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide';
|
||||
|
||||
export class StatusBarController {
|
||||
private _host: LitElement;
|
||||
@@ -14,6 +15,7 @@ export class StatusBarController {
|
||||
|
||||
private _popupTimer = new Timer();
|
||||
private _items: StatusBarItem[] = [];
|
||||
private _autoHideState: AutoHideState | null = null;
|
||||
|
||||
constructor(host: LitElement) {
|
||||
this._host = host;
|
||||
@@ -80,7 +82,19 @@ export class StatusBarController {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public setAutoHideState(state: AutoHideState): void {
|
||||
this._autoHideState = state;
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public shouldRender(): boolean {
|
||||
if (
|
||||
this._config &&
|
||||
this._autoHideState &&
|
||||
evaluateAutoHidden(this._config.auto_hide, this._autoHideState)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this._items.some(
|
||||
(item) => item.enabled !== false && (item.sufficient || item.permanent),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
|
||||
import { MicrophoneState } from '../card-controller/types.js';
|
||||
import { ActionConfig } from '../config/schema/actions/types.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import callControlsStyle from '../scss/call-controls.scss';
|
||||
import {
|
||||
createCallEndAction,
|
||||
createGeneralAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../utils/action.js';
|
||||
import { hasPopOutAnimationEnded } from '../utils/animation.js';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||
|
||||
/**
|
||||
* The on-screen overlay shown during an active two-way audio call: a centered
|
||||
* pill with end-call, microphone-toggle, and mute-toggle buttons.
|
||||
*
|
||||
* This is a purely presentational control showing state and emitting intents.
|
||||
* The end-call and microphone buttons dispatch actions; the audio-out button
|
||||
* fires an `advanced-camera-card:call:mute-toggle` event for the host to act on.
|
||||
*/
|
||||
@customElement('advanced-camera-card-call-controls')
|
||||
export class AdvancedCameraCardCallControls extends LitElement {
|
||||
// Whether a call is in progress.
|
||||
@property({ attribute: false })
|
||||
public active = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
|
||||
@property({ attribute: false })
|
||||
public muted?: boolean;
|
||||
|
||||
// The size, in pixels, of the control buttons.
|
||||
@property({ attribute: false })
|
||||
public buttonSize?: number;
|
||||
|
||||
// True while the exit animation plays after `active` turns false.
|
||||
@state()
|
||||
private _exiting = false;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('buttonSize') && this.buttonSize) {
|
||||
this.style.setProperty(
|
||||
'--advanced-camera-card-call-controls-button-size',
|
||||
`${this.buttonSize}px`,
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('active')) {
|
||||
// Keep the pill visible through its exit animation when a call ends; a
|
||||
// call (re)starting cancels any in-progress exit.
|
||||
this._exiting = !this.active && !!changedProps.get('active');
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.active && !this._exiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const microphoneMuted = this.microphoneState?.muted ?? true;
|
||||
const audioAvailable = this.muted !== undefined;
|
||||
const audioMuted = this.muted ?? true;
|
||||
|
||||
return html`<div class="overlay">
|
||||
<div
|
||||
class=${classMap({ panel: true, exiting: this._exiting })}
|
||||
@click=${(ev: Event) => stopEventFromActivatingCardWideActions(ev)}
|
||||
@animationend=${this._handleAnimationEnd}
|
||||
>
|
||||
${this._renderButton(
|
||||
'mdi:phone-hangup',
|
||||
localize('config.live.controls.call.end'),
|
||||
{
|
||||
emphasis: 'critical',
|
||||
action: createCallEndAction(),
|
||||
},
|
||||
)}
|
||||
${this._renderButton(
|
||||
microphoneMuted ? 'mdi:microphone-off' : 'mdi:microphone',
|
||||
microphoneMuted
|
||||
? localize('config.live.controls.call.unmute_microphone')
|
||||
: localize('config.live.controls.call.mute_microphone'),
|
||||
{
|
||||
emphasis: microphoneMuted ? undefined : 'critical',
|
||||
action: createGeneralAction(
|
||||
microphoneMuted ? 'microphone_unmute' : 'microphone_mute',
|
||||
),
|
||||
},
|
||||
)}
|
||||
${this._renderButton(
|
||||
audioMuted ? 'mdi:volume-off' : 'mdi:volume-high',
|
||||
audioMuted
|
||||
? localize('config.live.controls.call.unmute_audio')
|
||||
: localize('config.live.controls.call.mute_audio'),
|
||||
{
|
||||
disabled: !audioAvailable,
|
||||
handler: () => fireAdvancedCameraCardEvent(this, 'call:mute-toggle'),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _handleAnimationEnd = (ev: AnimationEvent): void => {
|
||||
if (hasPopOutAnimationEnded(ev)) {
|
||||
this._exiting = false;
|
||||
}
|
||||
};
|
||||
|
||||
private _renderButton(
|
||||
icon: string,
|
||||
label: string,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
emphasis?: 'critical';
|
||||
action?: ActionConfig;
|
||||
handler?: () => void;
|
||||
},
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.label=${label}
|
||||
title=${label}
|
||||
?disabled=${!!options?.disabled}
|
||||
class=${options?.emphasis === 'critical' ? 'critical' : ''}
|
||||
@click=${() => {
|
||||
if (options?.handler) {
|
||||
options.handler();
|
||||
} else if (options?.action) {
|
||||
dispatchActionExecutionRequest(this, { actions: options.action });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ha-icon icon=${icon}></ha-icon>
|
||||
</ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(callControlsStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-call-controls': AdvancedCameraCardCallControls;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
||||
import { CallSession } from '../../card-controller/call/types.js';
|
||||
import { MicrophoneState } from '../../card-controller/types.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { resolveAutoHideState } from '../../components-lib/auto-hide.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
||||
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
||||
@@ -30,9 +32,10 @@ import { HomeAssistant } from '../../ha/types.js';
|
||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||
import { getStreamCameraID } from '../../utils/substream.js';
|
||||
import { getTextDirection } from '../../utils/text-direction.js';
|
||||
import { getStreamCameraID } from '../../view/substream.js';
|
||||
import { View } from '../../view/view.js';
|
||||
import '../call-controls.js';
|
||||
import '../carousel';
|
||||
import '../next-prev-control.js';
|
||||
import '../ptz.js';
|
||||
@@ -70,6 +73,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
|
||||
@property({ attribute: false })
|
||||
public call?: CallSession;
|
||||
|
||||
@property({ attribute: false })
|
||||
public locked?: boolean;
|
||||
|
||||
@@ -84,10 +90,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
private _ptzDragController = new PTZDragController(this);
|
||||
|
||||
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
||||
getTargetID: () =>
|
||||
this.viewFilterCameraID ??
|
||||
this.viewManagerEpoch?.manager.getView()?.camera ??
|
||||
null,
|
||||
getTargetID: () => this._getCarouselCameraID(),
|
||||
callback: () => this._mediaHeightController.recalculate(),
|
||||
});
|
||||
|
||||
@@ -138,6 +141,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
private _getTransitionEffect = (): TransitionEffect =>
|
||||
this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect;
|
||||
|
||||
// The cameraID this carousel currently represents: the filtered camera when
|
||||
// the carousel is scoped to one, otherwise the camera of the active view.
|
||||
private _getCarouselCameraID(): string | null {
|
||||
return (
|
||||
this.viewFilterCameraID ?? this.viewManagerEpoch?.manager.getView()?.camera ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private _getSelectedCameraIndex(): number {
|
||||
if (this.viewFilterCameraID) {
|
||||
// If the carousel is limited to a single cameraID, the first (only)
|
||||
@@ -154,7 +165,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('microphoneState') || changedProps.has('liveConfig')) {
|
||||
if (changedProps.has('liveConfig')) {
|
||||
this._mediaActionsController.setOptions({
|
||||
playerSelector: ADVANCED_CAMERA_CARD_LIVE_PROVIDER,
|
||||
...(this.liveConfig?.auto_play && {
|
||||
@@ -169,13 +180,27 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
...(this.liveConfig?.auto_unmute && {
|
||||
autoUnmuteConditions: this.liveConfig.auto_unmute,
|
||||
}),
|
||||
...((this.liveConfig?.auto_unmute || this.liveConfig?.auto_mute) && {
|
||||
microphoneState: this.microphoneState,
|
||||
...(this.liveConfig && {
|
||||
microphoneMuteSeconds:
|
||||
this.liveConfig.microphone.mute_after_microphone_mute_seconds,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (changedProps.has('microphoneState') && this.microphoneState) {
|
||||
this._mediaActionsController.setMicrophoneState(this.microphoneState);
|
||||
}
|
||||
if (
|
||||
changedProps.has('call') ||
|
||||
changedProps.has('viewManagerEpoch') ||
|
||||
changedProps.has('viewFilterCameraID')
|
||||
) {
|
||||
// Scope the call-active signal to the carousel that owns the call: in
|
||||
// grid mode every carousel receives `.call`, but only the call camera's
|
||||
// audio should be acted on.
|
||||
this._mediaActionsController.setCallActive(
|
||||
this.call?.cameraID === this._getCarouselCameraID(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _getSlides(): TemplateResult[] {
|
||||
@@ -232,15 +257,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
|
||||
|
||||
const isSelectedSlide = !!view?.camera && cameraID === view.camera;
|
||||
const microphoneStream = this._getRelevantMicrophoneStream(cameraID, view);
|
||||
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
${keyed(
|
||||
mediaEpoch,
|
||||
html`<advanced-camera-card-live-provider
|
||||
.microphoneState=${view?.camera === cameraID
|
||||
? this.microphoneState
|
||||
: undefined}
|
||||
.microphoneStream=${microphoneStream}
|
||||
.camera=${resolvedCamera}
|
||||
.targetID=${cameraID}
|
||||
.label=${cameraMetadata?.title ?? ''}
|
||||
@@ -270,6 +294,32 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
return view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
||||
}
|
||||
|
||||
// Return a microphone stream only for the camera the call runs on, and
|
||||
// only while that camera's engaged stream is still the call's audio source.
|
||||
// Keying off the call session (not the selected slide) keeps the microphone
|
||||
// routed to the call's camera, and stops transmission if the substream has
|
||||
// since changed.
|
||||
private _getRelevantMicrophoneStream(
|
||||
cameraID: string,
|
||||
view?: View | null,
|
||||
): MediaStream | null {
|
||||
const isRelevant =
|
||||
this.call?.cameraID === cameraID &&
|
||||
this._getSubstreamCameraID(cameraID, view) ===
|
||||
(this.call.callCameraID ?? cameraID);
|
||||
return isRelevant ? this.microphoneState?.stream ?? null : null;
|
||||
}
|
||||
|
||||
private _toggleMute(): void {
|
||||
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
|
||||
// Fire-and-forget; the `volumechange` event drives the re-render.
|
||||
if (controller?.isMuted()) {
|
||||
controller.unmute();
|
||||
} else {
|
||||
controller?.mute();
|
||||
}
|
||||
}
|
||||
|
||||
private _getCameraNeighbors(): CameraNeighbors | null {
|
||||
const cameraIDs = this.cameraManager
|
||||
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
|
||||
@@ -330,6 +380,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
.controlConfig=${this.liveConfig?.controls.next_previous}
|
||||
.label=${neighbor?.metadata?.title ?? ''}
|
||||
.icon=${neighbor?.metadata?.icon}
|
||||
.autoHideState=${resolveAutoHideState(!!this.call)}
|
||||
?disabled=${!neighbor}
|
||||
?locked=${!!this.locked}
|
||||
@click=${(ev) => {
|
||||
@@ -354,12 +405,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
const hasMultipleCameras = slides.length > 1;
|
||||
const neighbors = this._getCameraNeighbors();
|
||||
|
||||
const carouselCameraID = this._getCarouselCameraID();
|
||||
const streamAwareCameraID = getStreamCameraID(view, this.viewFilterCameraID);
|
||||
const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID);
|
||||
|
||||
const forcePTZVisibility =
|
||||
!this._mediaLoadedInfoSinkController.has() ||
|
||||
(!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) ||
|
||||
carouselCameraID !== view.camera ||
|
||||
view.context?.ptzControls?.enabled === false
|
||||
? false
|
||||
: view.context?.ptzControls?.enabled;
|
||||
@@ -370,6 +422,10 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
!gesturesPTZActive &&
|
||||
!this.locked;
|
||||
|
||||
const isCallActive = this.call?.cameraID === carouselCameraID;
|
||||
const callMediaPlayerController =
|
||||
this._mediaLoadedInfoSinkController.get()?.mediaPlayerController ?? null;
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
@@ -379,6 +435,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
.wheelScrolling=${this.liveConfig?.controls.wheel}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@advanced-camera-card:carousel:select=${this._setViewHandler.bind(this)}
|
||||
@advanced-camera-card:media:volumechange=${() =>
|
||||
// Re-render so the call-controls are updated.
|
||||
this.requestUpdate()}
|
||||
>
|
||||
${this._renderNextPrevious('left', neighbors)}
|
||||
<!-- -->
|
||||
@@ -395,6 +454,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
.type=${this._getDisplayPTZType(streamAwareCameraID)}
|
||||
>
|
||||
</advanced-camera-card-ptz>
|
||||
<advanced-camera-card-call-controls
|
||||
.active=${isCallActive}
|
||||
.microphoneState=${this.microphoneState}
|
||||
.muted=${callMediaPlayerController?.isMuted()}
|
||||
.buttonSize=${this.liveConfig.controls.call.button_size}
|
||||
@advanced-camera-card:call:mute-toggle=${() => this._toggleMute()}
|
||||
>
|
||||
</advanced-camera-card-call-controls>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { MicrophoneState } from '../../card-controller/types.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { CallSession } from '../../card-controller/call/types.js';
|
||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||
import { LiveConfig } from '../../config/schema/live.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
@@ -39,6 +40,9 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
|
||||
@property({ attribute: false })
|
||||
public call?: CallSession;
|
||||
|
||||
@property({ attribute: false })
|
||||
public locked?: boolean;
|
||||
|
||||
@@ -47,7 +51,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
||||
|
||||
private _renderCarousel(cameraID?: string): TemplateResult {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const triggeredCameraID = cameraID ?? view?.camera;
|
||||
const carouselCameraID = cameraID ?? view?.camera;
|
||||
|
||||
// Get the camera's grid width factor from its dimensions config.
|
||||
const gridWidthFactor = cameraID
|
||||
@@ -66,9 +70,13 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.microphoneState=${this.microphoneState}
|
||||
.call=${this.call}
|
||||
.locked=${this.locked}
|
||||
?triggered=${triggeredCameraID &&
|
||||
!!this.triggeredCameraIDs?.has(triggeredCameraID)}
|
||||
?triggered=${carouselCameraID &&
|
||||
!!this.triggeredCameraIDs?.has(carouselCameraID)}
|
||||
?transmitting=${this.microphoneState?.muted === false &&
|
||||
!!this.call &&
|
||||
this.call.cameraID === carouselCameraID}
|
||||
>
|
||||
</advanced-camera-card-live-carousel>
|
||||
`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { MicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||
import { MicrophoneState } from '../../card-controller/types.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { CallSession } from '../../card-controller/call/types.js';
|
||||
import { MicrophoneActionsController } from '../../components-lib/live/microphone-actions-controller.js';
|
||||
import '../../components-lib/live/types.js';
|
||||
import { LiveConfig } from '../../config/schema/live.js';
|
||||
@@ -43,6 +44,9 @@ export class AdvancedCameraCardLive extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
|
||||
@property({ attribute: false })
|
||||
public call?: CallSession;
|
||||
|
||||
@property({ attribute: false })
|
||||
public locked?: boolean;
|
||||
|
||||
@@ -81,6 +85,9 @@ export class AdvancedCameraCardLive extends LitElement {
|
||||
view?.is('live') ? view.camera ?? null : null,
|
||||
);
|
||||
}
|
||||
if (changedProps.has('call')) {
|
||||
this._microphoneActionsController.setCallActive(!!this.call);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
@@ -96,6 +103,7 @@ export class AdvancedCameraCardLive extends LitElement {
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.microphoneState=${this.microphoneState}
|
||||
.call=${this.call}
|
||||
.locked=${this.locked}
|
||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { Camera } from '../../camera-manager/camera.js';
|
||||
import { MicrophoneState } from '../../card-controller/types.js';
|
||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
||||
@@ -51,7 +50,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
public microphoneStream?: MediaStream | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public zoomSettings?: PartialZoomSettings | null;
|
||||
@@ -340,7 +339,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
.hass=${this.hass}
|
||||
.camera=${this.camera}
|
||||
.targetID=${this.targetID}
|
||||
.microphoneState=${this.microphoneState}
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
.microphoneConfig=${this.liveConfig.microphone}
|
||||
?controls=${this._getEffectiveBuiltinControls()}
|
||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { Camera } from '../../../../camera-manager/camera.js';
|
||||
import { MicrophoneState } from '../../../../card-controller/types.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
||||
import { SignedURLController } from '../../../../components-lib/signed-url-controller.js';
|
||||
@@ -35,7 +34,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
||||
public targetID?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
public microphoneStream?: MediaStream | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneConfig?: MicrophoneConfig;
|
||||
@@ -103,7 +102,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
||||
this._player = new VideoRTC();
|
||||
this._player.targetID = this.targetID ?? null;
|
||||
this._player.mediaPlayerController = this._mediaPlayerController;
|
||||
this._player.microphoneStream = this.microphoneState?.stream ?? null;
|
||||
this._player.microphoneStream = this.microphoneStream ?? null;
|
||||
this._player.src = src;
|
||||
this._player.visibilityCheck = false;
|
||||
this._player.setControls(this.controls);
|
||||
@@ -137,11 +136,11 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
||||
this._player.setControls(this.controls);
|
||||
}
|
||||
|
||||
if (this._player && changedProps.has('microphoneState')) {
|
||||
if (this._player && changedProps.has('microphoneStream')) {
|
||||
// VideoRTC owns the transition: it updates microphoneStream, swaps the
|
||||
// track on the pre-armed transceiver, and validates against stale async
|
||||
// completions before any reconnect fallback. Fire-and-forget is fine.
|
||||
/* async */ this._player.setMicrophoneStream(this.microphoneState?.stream ?? null);
|
||||
/* async */ this._player.setMicrophoneStream(this.microphoneStream ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import type { LockManagerEpoch } from '../card-controller/lock/types';
|
||||
import type { AutoHideState } from '../components-lib/auto-hide.js';
|
||||
import { MenuController } from '../components-lib/menu-controller.js';
|
||||
import type { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
||||
import type { MenuConfig } from '../config/schema/menu.js';
|
||||
@@ -11,6 +12,7 @@ import type { EntityRegistryManager } from '../ha/registry/entity/types.js';
|
||||
import type { HomeAssistant } from '../ha/types.js';
|
||||
import menuStyle from '../scss/menu.scss';
|
||||
import { hasAction } from '../utils/action.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import './icon.js';
|
||||
import './submenu/select-button.js';
|
||||
import './submenu/submenu-button';
|
||||
@@ -28,6 +30,9 @@ export class AdvancedCameraCardMenu extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public lockManagerEpoch?: LockManagerEpoch;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public autoHideState?: AutoHideState;
|
||||
|
||||
set menuConfig(menuConfig: MenuConfig) {
|
||||
this._controller.setMenuConfig(menuConfig);
|
||||
}
|
||||
@@ -44,6 +49,9 @@ export class AdvancedCameraCardMenu extends LitElement {
|
||||
if (changedProps.has('lockManagerEpoch')) {
|
||||
this._controller.setLockManagerEpoch(this.lockManagerEpoch);
|
||||
}
|
||||
if (changedProps.has('autoHideState') && this.autoHideState) {
|
||||
this._controller.setAutoHideState(this.autoHideState);
|
||||
}
|
||||
}
|
||||
|
||||
public toggleMenu(): void {
|
||||
@@ -156,9 +164,7 @@ export class AdvancedCameraCardMenu extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const config = this._controller.getMenuConfig();
|
||||
const style = config?.style;
|
||||
if (!config || style === 'none') {
|
||||
if (!this._controller.shouldRender()) {
|
||||
return;
|
||||
}
|
||||
const matchingButtons = this._controller.getButtons('matching');
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { AutoHideState, isAutoHidden } from '../components-lib/auto-hide.js';
|
||||
import { NextPreviousControlConfig } from '../config/schema/common/controls/next-previous.js';
|
||||
import { Icon } from '../config/schema/common/icon.js';
|
||||
import { HomeAssistant } from '../ha/types.js';
|
||||
import controlStyle from '../scss/next-previous-control.scss';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||
|
||||
@@ -38,6 +40,9 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement {
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public disabled = false;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public autoHideState?: AutoHideState;
|
||||
|
||||
// Label that is used for ARIA support and as tooltip.
|
||||
@property() label = '';
|
||||
|
||||
@@ -48,7 +53,13 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement {
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') {
|
||||
if (
|
||||
this.disabled ||
|
||||
!this._controlConfig ||
|
||||
this._controlConfig.style == 'none' ||
|
||||
(this.autoHideState &&
|
||||
isAutoHidden(this._controlConfig.auto_hide, this.autoHideState))
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { handleControlAction } from '../../components-lib/notification/action.js
|
||||
import { Notification } from '../../config/schema/actions/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import notificationPopupStyle from '../../scss/notification-popup.scss';
|
||||
import { hasPopOutAnimationEnded } from '../../utils/animation.js';
|
||||
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
||||
import {
|
||||
renderControl,
|
||||
@@ -80,7 +81,7 @@ export class AdvancedCameraCardNotification extends LitElement {
|
||||
};
|
||||
|
||||
private _handleAnimationEnd = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'slideDown') {
|
||||
if (hasPopOutAnimationEnded(ev)) {
|
||||
dispatchDismissNotificationEvent(this);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import type { AutoHideState } from '../components-lib/auto-hide.js';
|
||||
import { StatusBarController } from '../components-lib/status-bar-controller';
|
||||
import { StatusBarItem } from '../config/schema/actions/types.js';
|
||||
import { StatusBarConfig } from '../config/schema/status-bar.js';
|
||||
@@ -28,6 +29,9 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public config?: StatusBarConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public autoHideState?: AutoHideState;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
// Always set config before items.
|
||||
if (changedProperties.has('config') && this.config) {
|
||||
@@ -37,6 +41,10 @@ export class AdvancedCameraCardStatusBar extends LitElement {
|
||||
if (changedProperties.has('items')) {
|
||||
this._controller.setItems(this.items ?? []);
|
||||
}
|
||||
|
||||
if (changedProperties.has('autoHideState') && this.autoHideState) {
|
||||
this._controller.setAutoHideState(this.autoHideState);
|
||||
}
|
||||
}
|
||||
|
||||
/** Theme-related styling is dynamically injected into the status bar depending on
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { resolveAutoHideState } from '../../components-lib/auto-hide.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
||||
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
||||
@@ -302,6 +303,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${neighbors?.[scrollDirection]?.media.getThumbnail() ?? undefined}
|
||||
.label=${neighbors?.[scrollDirection]?.media.getTitle() ?? ''}
|
||||
.autoHideState=${resolveAutoHideState()}
|
||||
?disabled=${!neighbors?.[scrollDirection]}
|
||||
@click=${(ev: Event) => {
|
||||
scroll(scrollDirection);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MicrophoneManager } from '../card-controller/microphone-manager.js';
|
||||
import { MicrophoneState } from '../card-controller/types.js';
|
||||
import { ViewItemManager } from '../card-controller/view/item-manager.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import { CallSession } from '../card-controller/call/types.js';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js';
|
||||
import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js';
|
||||
import { RawAdvancedCameraCardConfig } from '../config/types.js';
|
||||
@@ -67,6 +68,9 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public microphoneState?: MicrophoneState;
|
||||
|
||||
@property({ attribute: false })
|
||||
public call?: CallSession;
|
||||
|
||||
@property({ attribute: false })
|
||||
public locked?: boolean;
|
||||
|
||||
@@ -240,6 +244,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.microphoneManager=${this.microphoneManager}
|
||||
.microphoneState=${this.microphoneState}
|
||||
.call=${this.call}
|
||||
.locked=${this.locked}
|
||||
.triggeredCameraIDs=${this.triggeredCameraIDs}
|
||||
class="${classMap(liveClasses)}"
|
||||
|
||||
@@ -250,11 +250,11 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface {
|
||||
};
|
||||
case 'microphone':
|
||||
return {
|
||||
result:
|
||||
(condition.connected === undefined ||
|
||||
newState?.microphone?.connected === condition.connected) &&
|
||||
(condition.muted === undefined ||
|
||||
newState?.microphone?.muted === condition.muted),
|
||||
result: newState?.microphone?.muted === condition.muted,
|
||||
};
|
||||
case 'call':
|
||||
return {
|
||||
result: (condition.call ?? true) === (newState?.call ?? false),
|
||||
};
|
||||
case 'key':
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,11 @@ import { HomeAssistant } from '../ha/types';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
|
||||
export interface ConditionState {
|
||||
call?: boolean;
|
||||
camera?: string;
|
||||
// The engaged substream for the selected camera (absent when the camera's own
|
||||
// stream is used).
|
||||
substreamID?: string;
|
||||
config?: AdvancedCameraCardConfig;
|
||||
displayMode?: ViewDisplayMode;
|
||||
expand?: boolean;
|
||||
|
||||
@@ -791,6 +791,42 @@ const frigateCardToAdvancedCameraCardTransform = (
|
||||
return modified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a `condition: microphone` condition with the (removed) `connected`
|
||||
* field into a `condition: call` node. Operates on a single condition object in
|
||||
* place. When both `connected` and `muted` are present, splits into a
|
||||
* two-condition `and` (the only way to preserve both semantics now that
|
||||
* `connected` no longer lives on `microphone`).
|
||||
*
|
||||
* @returns `true` if the node was modified.
|
||||
*/
|
||||
const microphoneConnectedToCallTransform = (data: unknown): boolean => {
|
||||
if (typeof data !== 'object' || !data || data['condition'] !== 'microphone') {
|
||||
return false;
|
||||
}
|
||||
const connected = data['connected'];
|
||||
if (typeof connected !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
const muted = data['muted'];
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
delete data[key];
|
||||
}
|
||||
|
||||
if (typeof muted === 'boolean') {
|
||||
data['condition'] = 'and';
|
||||
data['conditions'] = [
|
||||
{ condition: 'call', call: connected },
|
||||
{ condition: 'microphone', muted: muted },
|
||||
];
|
||||
} else {
|
||||
data['condition'] = 'call';
|
||||
data['call'] = connected;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data || Array.isArray(data)) {
|
||||
return data;
|
||||
@@ -999,4 +1035,20 @@ const UPGRADES = [
|
||||
CONF_CAMERAS,
|
||||
upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform),
|
||||
),
|
||||
|
||||
// microphone.connected → call condition migration. Conditions live under
|
||||
// overrides, elements, and automations.
|
||||
upgradeArrayOfObjects(CONF_OVERRIDES, (override) =>
|
||||
upgradeObjectRecursively(microphoneConnectedToCallTransform)(override),
|
||||
),
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(microphoneConnectedToCallTransform)(
|
||||
typeof data === 'object' && data ? data[CONF_ELEMENTS] : {},
|
||||
);
|
||||
},
|
||||
(data: unknown): boolean => {
|
||||
return upgradeObjectRecursively(microphoneConnectedToCallTransform)(
|
||||
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,18 +10,16 @@ import {
|
||||
CONF_MENU_BUTTONS_MEDIA_PLAYER,
|
||||
CONF_MENU_BUTTONS_MUTE,
|
||||
CONF_MENU_BUTTONS_PLAY,
|
||||
CONF_MENU_STYLE,
|
||||
} from '../../const.js';
|
||||
|
||||
export const CASTING_PROFILE = {
|
||||
[CONF_LIVE_CONTROLS_BUILTIN]: false,
|
||||
[CONF_MEDIA_VIEWER_CONTROLS_BUILTIN]: false,
|
||||
|
||||
// TVs are generally not touch-enabled, so we don't want to show the menu
|
||||
[CONF_MENU_STYLE]: 'none',
|
||||
|
||||
// But in case the user enables the menu, let's make sure to enable the
|
||||
// buttons that make sense and disable the ones that don't
|
||||
// TVs are generally not touch-enabled, so the menu auto-hides while casting
|
||||
// (the `casting` default in `menu.auto_hide`). Should it nonetheless be
|
||||
// shown, make sure the buttons that make sense are enabled and the ones that
|
||||
// don't are disabled.
|
||||
[`${CONF_MENU_BUTTONS_PLAY}.enabled`]: true,
|
||||
[`${CONF_MENU_BUTTONS_MUTE}.enabled`]: true,
|
||||
[`${CONF_MENU_BUTTONS_FULLSCREEN}.enabled`]: false,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
||||
|
||||
export const callEndActionConfigSchema =
|
||||
advancedCameraCardCustomActionsBaseSchema.extend({
|
||||
advanced_camera_card_action: z.literal('call_end'),
|
||||
});
|
||||
export type CallEndActionConfig = z.infer<typeof callEndActionConfigSchema>;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
||||
|
||||
export const callStartActionConfigSchema =
|
||||
advancedCameraCardCustomActionsBaseSchema.extend({
|
||||
advanced_camera_card_action: z.literal('call_start'),
|
||||
|
||||
// The camera to start the call on. Defaults to the selected camera.
|
||||
camera: z.string().optional(),
|
||||
|
||||
// The 2-way-audio stream to carry the call: Could be `camera` itself, or
|
||||
// one of its 2-way-audio dependencies. Defaults to the first eligible.
|
||||
stream: z.string().optional(),
|
||||
});
|
||||
export type CallStartActionConfig = z.infer<typeof callStartActionConfigSchema>;
|
||||
@@ -3,6 +3,8 @@ import { linkSchema } from '../common/link';
|
||||
import { severitySchema } from '../common/severity';
|
||||
import { statusBarItemBaseSchema } from '../common/status-bar';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './custom/base';
|
||||
import { callEndActionConfigSchema } from './custom/call-end';
|
||||
import { callStartActionConfigSchema } from './custom/call-start';
|
||||
import { cameraSelectActionConfigSchema } from './custom/camera-select';
|
||||
import { viewDisplayModeActionConfigSchema } from './custom/display-mode';
|
||||
import { effectActionConfigSchema } from './custom/effect';
|
||||
@@ -58,6 +60,8 @@ export const statusBarActionConfigSchema: z.ZodSchema<StatusBarActionConfig> =
|
||||
});
|
||||
|
||||
const advancedCameraCardCustomActionSchema = z.union([
|
||||
callEndActionConfigSchema,
|
||||
callStartActionConfigSchema,
|
||||
cameraSelectActionConfigSchema,
|
||||
effectActionConfigSchema,
|
||||
generalActionConfigSchema,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Conditions under which the menu or status bar auto-hides.
|
||||
export const AUTO_HIDE_CONDITIONS = ['call', 'casting'] as const;
|
||||
|
||||
export type AutoHideCondition = (typeof AUTO_HIDE_CONDITIONS)[number];
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { AUTO_HIDE_CONDITIONS } from '../auto-hide';
|
||||
import { BUTTON_SIZE_MIN } from '../const';
|
||||
|
||||
export const nextPreviousControlConfigSchema = z.object({
|
||||
auto_hide: z.enum(AUTO_HIDE_CONDITIONS).array(),
|
||||
style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']),
|
||||
size: z.number().min(BUTTON_SIZE_MIN),
|
||||
});
|
||||
|
||||
@@ -4,15 +4,24 @@ export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as cons
|
||||
export const MEDIA_MUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
'microphone',
|
||||
'call',
|
||||
] as const;
|
||||
|
||||
export const MEDIA_UNMUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||
'microphone',
|
||||
'call',
|
||||
] as const;
|
||||
|
||||
export const MICROPHONE_MUTE_CONDITIONS = MEDIA_ACTION_NEGATIVE_CONDITIONS;
|
||||
export const MICROPHONE_UNMUTE_CONDITIONS = MEDIA_ACTION_POSITIVE_CONDITIONS;
|
||||
export const MICROPHONE_MUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
'call',
|
||||
] as const;
|
||||
|
||||
export const MICROPHONE_UNMUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||
'call',
|
||||
] as const;
|
||||
|
||||
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const callConditionSchema = z.object({
|
||||
condition: z.literal('call'),
|
||||
call: z.boolean().optional(),
|
||||
});
|
||||
@@ -2,6 +2,5 @@ import { z } from 'zod';
|
||||
|
||||
export const microphoneConditionSchema = z.object({
|
||||
condition: z.literal('microphone'),
|
||||
connected: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
muted: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { callConditionSchema } from './custom/call';
|
||||
import { cameraConditionSchema } from './custom/camera';
|
||||
import { configConditionSchema } from './custom/config';
|
||||
import { displayModeConditionSchema } from './custom/display-mode';
|
||||
@@ -69,6 +70,7 @@ export const advancedCameraCardConditionSchema = z.union([
|
||||
templateConditionSchema,
|
||||
|
||||
// Custom conditions:
|
||||
callConditionSchema,
|
||||
cameraConditionSchema,
|
||||
configConditionSchema,
|
||||
displayModeConditionSchema,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { actionsSchema } from './actions/types';
|
||||
import { BUTTON_SIZE_MIN } from './common/const';
|
||||
import { nextPreviousControlConfigSchema } from './common/controls/next-previous';
|
||||
import { ptzControlsConfigSchema, ptzControlsDefaults } from './common/controls/ptz';
|
||||
import {
|
||||
@@ -23,23 +24,37 @@ import { transitionEffectConfigSchema } from './common/transition-effect';
|
||||
|
||||
const microphoneConfigDefault = {
|
||||
always_connected: false,
|
||||
auto_mute: [],
|
||||
auto_mute: ['call' as const],
|
||||
auto_unmute: [],
|
||||
disconnect_seconds: 90,
|
||||
lock: true,
|
||||
mute_after_microphone_mute_seconds: 60,
|
||||
};
|
||||
|
||||
const callConfigDefault = {
|
||||
button_size: 40,
|
||||
lock: true,
|
||||
};
|
||||
|
||||
const callConfigSchema = z.object({
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(callConfigDefault.button_size),
|
||||
lock: z.boolean().default(callConfigDefault.lock),
|
||||
});
|
||||
|
||||
const microphoneConfigSchema = z
|
||||
.object({
|
||||
always_connected: z.boolean().default(microphoneConfigDefault.always_connected),
|
||||
auto_mute: z.enum(MICROPHONE_MUTE_CONDITIONS).array().default([]),
|
||||
auto_unmute: z.enum(MICROPHONE_UNMUTE_CONDITIONS).array().default([]),
|
||||
auto_mute: z
|
||||
.enum(MICROPHONE_MUTE_CONDITIONS)
|
||||
.array()
|
||||
.default(microphoneConfigDefault.auto_mute),
|
||||
auto_unmute: z
|
||||
.enum(MICROPHONE_UNMUTE_CONDITIONS)
|
||||
.array()
|
||||
.default(microphoneConfigDefault.auto_unmute),
|
||||
disconnect_seconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.default(microphoneConfigDefault.disconnect_seconds),
|
||||
lock: z.boolean().default(microphoneConfigDefault.lock),
|
||||
mute_after_microphone_mute_seconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
@@ -52,7 +67,7 @@ export const liveConfigDefault = {
|
||||
auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS],
|
||||
auto_pause: [],
|
||||
auto_mute: [...MEDIA_MUTE_CONDITIONS],
|
||||
auto_unmute: ['microphone' as const],
|
||||
auto_unmute: ['microphone' as const, 'call' as const],
|
||||
preload: false,
|
||||
lazy_load: true,
|
||||
lazy_unload: [],
|
||||
@@ -62,7 +77,9 @@ export const liveConfigDefault = {
|
||||
show_image_during_load: true,
|
||||
controls: {
|
||||
builtin: true,
|
||||
call: { ...callConfigDefault },
|
||||
next_previous: {
|
||||
auto_hide: ['call' as const, 'casting' as const],
|
||||
size: 48,
|
||||
style: 'chevrons' as const,
|
||||
},
|
||||
@@ -97,8 +114,12 @@ export const liveConfigSchema = z
|
||||
controls: z
|
||||
.object({
|
||||
builtin: z.boolean().default(liveConfigDefault.controls.builtin),
|
||||
call: callConfigSchema.default(liveConfigDefault.controls.call),
|
||||
next_previous: nextPreviousControlConfigSchema
|
||||
.extend({
|
||||
auto_hide: nextPreviousControlConfigSchema.shape.auto_hide.default(
|
||||
liveConfigDefault.controls.next_previous.auto_hide,
|
||||
),
|
||||
// Live cannot show thumbnails, remove that option.
|
||||
style: z
|
||||
.enum(['none', 'chevrons', 'icons'])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { AUTO_HIDE_CONDITIONS } from './common/auto-hide';
|
||||
import { BUTTON_SIZE_MIN, MENU_PRIORITY_DEFAULT } from './common/const';
|
||||
import { menuBaseSchema } from './elements/custom/menu/base';
|
||||
|
||||
@@ -32,10 +33,12 @@ const hiddenButtonDefault = {
|
||||
|
||||
export const menuConfigDefault = {
|
||||
alignment: 'left' as const,
|
||||
auto_hide: ['call' as const, 'casting' as const],
|
||||
button_size: 40,
|
||||
buttons: {
|
||||
// Clone per key so each button has its own default object. This avoids
|
||||
// shared nested default references between keys.
|
||||
call: { ...visibleButtonDefault },
|
||||
camera_ui: { ...visibleButtonDefault },
|
||||
cameras: { ...visibleButtonDefault },
|
||||
clips: { ...hiddenButtonDefault },
|
||||
@@ -86,8 +89,10 @@ export const menuConfigSchema = z
|
||||
style: z.enum(MENU_STYLES).default(menuConfigDefault.style),
|
||||
position: z.enum(MENU_POSITIONS).default(menuConfigDefault.position),
|
||||
alignment: z.enum(MENU_ALIGNMENTS).default(menuConfigDefault.alignment),
|
||||
auto_hide: z.enum(AUTO_HIDE_CONDITIONS).array().default(menuConfigDefault.auto_hide),
|
||||
buttons: z
|
||||
.object({
|
||||
call: visibleButtonSchema.default(menuConfigDefault.buttons.call),
|
||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
clips: hiddenButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { AUTO_HIDE_CONDITIONS } from './common/auto-hide';
|
||||
import { BUTTON_SIZE_MIN, STATUS_BAR_PRIORITY_DEFAULT } from './common/const';
|
||||
import { statusBarItemBaseSchema } from './common/status-bar';
|
||||
|
||||
@@ -36,6 +37,7 @@ const statusBarIssuesItemSchema = statusBarItemBaseSchema.extend({
|
||||
});
|
||||
|
||||
export const statusBarConfigDefault = {
|
||||
auto_hide: ['call' as const, 'casting' as const],
|
||||
height: 40,
|
||||
items: {
|
||||
engine: statusBarItemDefault,
|
||||
@@ -52,6 +54,10 @@ export const statusBarConfigDefault = {
|
||||
|
||||
export const statusBarConfigSchema = z
|
||||
.object({
|
||||
auto_hide: z
|
||||
.enum(AUTO_HIDE_CONDITIONS)
|
||||
.array()
|
||||
.default(statusBarConfigDefault.auto_hide),
|
||||
position: z.enum(STATUS_BAR_POSITIONS).default(statusBarConfigDefault.position),
|
||||
style: z.enum(STATUS_BAR_STYLES).default(statusBarConfigDefault.style),
|
||||
popup_seconds: z
|
||||
|
||||
@@ -30,6 +30,7 @@ export const viewerConfigDefault = {
|
||||
controls: {
|
||||
builtin: true,
|
||||
next_previous: {
|
||||
auto_hide: ['casting' as const],
|
||||
size: 48,
|
||||
style: 'thumbnails' as const,
|
||||
},
|
||||
@@ -44,6 +45,12 @@ export const viewerConfigDefault = {
|
||||
};
|
||||
|
||||
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
|
||||
// Calls only occur in the live view, so `call` is dropped from the common
|
||||
// enum.
|
||||
auto_hide: z
|
||||
.enum(['casting'])
|
||||
.array()
|
||||
.default(viewerConfigDefault.controls.next_previous.auto_hide),
|
||||
style: z
|
||||
.enum(['none', 'thumbnails', 'chevrons'])
|
||||
.default(viewerConfigDefault.controls.next_previous.style),
|
||||
|
||||
+12
-1
@@ -244,6 +244,8 @@ export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
|
||||
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN =
|
||||
`${CONF_MEDIA_VIEWER}.controls.builtin` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.next_previous.auto_hide` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
|
||||
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||
@@ -289,6 +291,11 @@ export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
|
||||
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
|
||||
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
|
||||
export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const;
|
||||
export const CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE =
|
||||
`${CONF_LIVE}.controls.call.button_size` as const;
|
||||
export const CONF_LIVE_CONTROLS_CALL_LOCK = `${CONF_LIVE}.controls.call.lock` as const;
|
||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE =
|
||||
`${CONF_LIVE}.controls.next_previous.auto_hide` as const;
|
||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||
`${CONF_LIVE}.controls.next_previous.style` as const;
|
||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||
@@ -363,7 +370,6 @@ export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE =
|
||||
`${CONF_LIVE}.microphone.auto_unmute` as const;
|
||||
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
||||
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
||||
export const CONF_LIVE_MICROPHONE_LOCK = `${CONF_LIVE}.microphone.lock` as const;
|
||||
export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS =
|
||||
`${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const;
|
||||
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
|
||||
@@ -406,6 +412,7 @@ export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
|
||||
|
||||
const CONF_MENU = 'menu' as const;
|
||||
export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const;
|
||||
export const CONF_MENU_AUTO_HIDE = `${CONF_MENU}.auto_hide` as const;
|
||||
export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const;
|
||||
export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const;
|
||||
export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
|
||||
@@ -420,6 +427,7 @@ export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
|
||||
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const;
|
||||
|
||||
export const CONF_STATUS_BAR = 'status_bar' as const;
|
||||
export const CONF_STATUS_BAR_AUTO_HIDE = `${CONF_STATUS_BAR}.auto_hide` as const;
|
||||
export const CONF_STATUS_BAR_POSITION = `${CONF_STATUS_BAR}.position` as const;
|
||||
export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const;
|
||||
export const CONF_STATUS_BAR_POPUP_SECONDS = `${CONF_STATUS_BAR}.popup_seconds` as const;
|
||||
@@ -461,3 +469,6 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
|
||||
// improved rendering performance.
|
||||
export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
|
||||
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
||||
|
||||
// The name of the exit keyframe defined in `scss/pop-animation.scss`.
|
||||
export const POP_OUT_ANIMATION_NAME = 'pop-out';
|
||||
|
||||
+88
-7
@@ -121,6 +121,9 @@ import {
|
||||
CONF_LIVE_AUTO_PLAY,
|
||||
CONF_LIVE_AUTO_UNMUTE,
|
||||
CONF_LIVE_CONTROLS_BUILTIN,
|
||||
CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE,
|
||||
CONF_LIVE_CONTROLS_CALL_LOCK,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_LIVE_CONTROLS_PTZ_HIDE_HOME,
|
||||
@@ -159,7 +162,6 @@ import {
|
||||
CONF_LIVE_MICROPHONE_AUTO_MUTE,
|
||||
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
|
||||
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
||||
CONF_LIVE_MICROPHONE_LOCK,
|
||||
CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
|
||||
CONF_LIVE_PRELOAD,
|
||||
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
|
||||
@@ -178,6 +180,7 @@ import {
|
||||
CONF_MEDIA_VIEWER_AUTO_PLAY,
|
||||
CONF_MEDIA_VIEWER_AUTO_UNMUTE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
||||
@@ -207,6 +210,7 @@ import {
|
||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||
CONF_MENU_ALIGNMENT,
|
||||
CONF_MENU_AUTO_HIDE,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_BUTTONS,
|
||||
CONF_MENU_POSITION,
|
||||
@@ -221,6 +225,7 @@ import {
|
||||
CONF_PERFORMANCE_STYLE_BOX_SHADOW,
|
||||
CONF_PROFILES,
|
||||
CONF_REMOTE_CONTROL_ENTITIES_CAMERA,
|
||||
CONF_STATUS_BAR_AUTO_HIDE,
|
||||
CONF_STATUS_BAR_HEIGHT,
|
||||
CONF_STATUS_BAR_ITEMS,
|
||||
CONF_STATUS_BAR_POPUP_SECONDS,
|
||||
@@ -308,6 +313,7 @@ const MENU_CAMERAS_MEDIA = 'cameras.media';
|
||||
const MENU_FOLDERS = 'folders';
|
||||
const MENU_FOLDERS_HA = 'folders.ha';
|
||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||
const MENU_LIVE_CONTROLS_CALL = 'live.controls.call';
|
||||
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
|
||||
const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz';
|
||||
const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
|
||||
@@ -384,6 +390,7 @@ const SUBMENU_DOC_LINKS: Record<string, string> = {
|
||||
[MENU_FOLDERS]: 'configuration/folders',
|
||||
[MENU_FOLDERS_HA]: 'configuration/folders?id=ha',
|
||||
[MENU_LIVE_CONTROLS]: 'configuration/live?id=controls',
|
||||
[MENU_LIVE_CONTROLS_CALL]: 'configuration/live?id=call',
|
||||
[MENU_LIVE_CONTROLS_NEXT_PREVIOUS]: 'configuration/live?id=next_previous',
|
||||
[MENU_LIVE_CONTROLS_PTZ]: 'configuration/live?id=ptz',
|
||||
[MENU_LIVE_CONTROLS_THUMBNAILS]: 'configuration/live?id=thumbnails',
|
||||
@@ -770,12 +777,33 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
},
|
||||
];
|
||||
|
||||
private _callMuteCondition: EditorSelectOption = {
|
||||
value: 'call',
|
||||
label: localize('config.common.media_action_conditions.call_mute'),
|
||||
};
|
||||
|
||||
private _callUnmuteCondition: EditorSelectOption = {
|
||||
value: 'call',
|
||||
label: localize('config.common.media_action_conditions.call_unmute'),
|
||||
};
|
||||
|
||||
private _microphoneMuteConditions: EditorSelectOption[] = [
|
||||
...this._mediaActionNegativeConditions,
|
||||
this._callMuteCondition,
|
||||
];
|
||||
|
||||
private _microphoneUnmuteConditions: EditorSelectOption[] = [
|
||||
...this._mediaActionPositiveConditions,
|
||||
this._callUnmuteCondition,
|
||||
];
|
||||
|
||||
private _mediaLiveUnmuteConditions: EditorSelectOption[] = [
|
||||
...this._mediaActionPositiveConditions,
|
||||
{
|
||||
value: 'microphone',
|
||||
label: localize('config.common.media_action_conditions.microphone_unmute'),
|
||||
},
|
||||
this._callUnmuteCondition,
|
||||
];
|
||||
|
||||
private _mediaLiveMuteConditions: EditorSelectOption[] = [
|
||||
@@ -784,6 +812,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
value: 'microphone',
|
||||
label: localize('config.common.media_action_conditions.microphone_mute'),
|
||||
},
|
||||
this._callMuteCondition,
|
||||
];
|
||||
|
||||
private _autoHideConditions: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'call', label: localize('config.common.auto_hide_conditions.call') },
|
||||
{
|
||||
value: 'casting',
|
||||
label: localize('config.common.auto_hide_conditions.casting'),
|
||||
},
|
||||
];
|
||||
|
||||
private _layoutFits: EditorSelectOption[] = [
|
||||
@@ -1973,9 +2011,11 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
domain: string,
|
||||
configPathStyle: string,
|
||||
configPathSize: string,
|
||||
configPathAutoHide: string,
|
||||
options?: {
|
||||
allowIcons?: boolean;
|
||||
allowThumbnails?: boolean;
|
||||
allowCall?: boolean;
|
||||
},
|
||||
): TemplateResult | void {
|
||||
return this._putInSubmenu(
|
||||
@@ -1999,6 +2039,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
min: BUTTON_SIZE_MIN,
|
||||
label: localize('config.common.controls.next_previous.size'),
|
||||
})}
|
||||
${this._renderOptionSelector(
|
||||
configPathAutoHide,
|
||||
this._autoHideConditions.filter(
|
||||
(item) => !!options?.allowCall || item.value !== 'call',
|
||||
),
|
||||
{
|
||||
multiple: true,
|
||||
label: localize('config.common.controls.next_previous.auto_hide'),
|
||||
},
|
||||
)}
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -3093,11 +3143,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
${this._renderOptionSelector(CONF_MENU_STYLE, this._menuStyles)}
|
||||
${this._renderOptionSelector(CONF_MENU_POSITION, this._menuPositions)}
|
||||
${this._renderOptionSelector(CONF_MENU_ALIGNMENT, this._menuAlignments)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_MENU_AUTO_HIDE,
|
||||
this._autoHideConditions,
|
||||
{
|
||||
multiple: true,
|
||||
label: localize('config.menu.auto_hide'),
|
||||
},
|
||||
)}
|
||||
${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, {
|
||||
min: BUTTON_SIZE_MIN,
|
||||
})}
|
||||
${[
|
||||
this._renderMenuButton('iris'),
|
||||
this._renderMenuButton('call'),
|
||||
this._renderMenuButton('camera_ui'),
|
||||
this._renderMenuButton('cameras'),
|
||||
this._renderMenuButton('clips'),
|
||||
@@ -3150,6 +3209,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_STATUS_BAR_POSITION,
|
||||
this._statusBarPositions,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_STATUS_BAR_AUTO_HIDE,
|
||||
this._autoHideConditions,
|
||||
{
|
||||
multiple: true,
|
||||
label: localize('config.status_bar.auto_hide'),
|
||||
},
|
||||
)}
|
||||
${this._renderNumberInput(CONF_STATUS_BAR_HEIGHT, {
|
||||
min: STATUS_BAR_HEIGHT_MIN,
|
||||
label: localize('config.status_bar.height'),
|
||||
@@ -3253,12 +3320,29 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
label: localize('config.common.controls.wheel'),
|
||||
},
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_LIVE_CONTROLS_CALL,
|
||||
true,
|
||||
'config.live.controls.call.editor_label',
|
||||
'mdi:phone',
|
||||
html`
|
||||
${this._renderSwitch(
|
||||
CONF_LIVE_CONTROLS_CALL_LOCK,
|
||||
this._defaults.live.controls.call.lock,
|
||||
)}
|
||||
${this._renderNumberInput(CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, {
|
||||
min: BUTTON_SIZE_MIN,
|
||||
})}
|
||||
`,
|
||||
)}
|
||||
${this._renderNextPreviousControls(
|
||||
MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
|
||||
{
|
||||
allowIcons: true,
|
||||
allowCall: true,
|
||||
},
|
||||
)}
|
||||
${this._renderThumbnailsControls(
|
||||
@@ -3353,20 +3437,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||
this._defaults.live.microphone.always_connected,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_LIVE_MICROPHONE_LOCK,
|
||||
this._defaults.live.microphone.lock,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_LIVE_MICROPHONE_AUTO_MUTE,
|
||||
this._mediaActionNegativeConditions,
|
||||
this._microphoneMuteConditions,
|
||||
{
|
||||
multiple: true,
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
|
||||
this._mediaActionPositiveConditions,
|
||||
this._microphoneUnmuteConditions,
|
||||
{
|
||||
multiple: true,
|
||||
},
|
||||
@@ -3498,6 +3578,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
|
||||
{
|
||||
allowThumbnails: true,
|
||||
},
|
||||
|
||||
@@ -207,6 +207,10 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"auto_hide_conditions": {
|
||||
"call": "During a two-way audio call",
|
||||
"casting": "While casting"
|
||||
},
|
||||
"controls": {
|
||||
"builtin": "Built-in video controls",
|
||||
"filter": {
|
||||
@@ -219,6 +223,7 @@
|
||||
}
|
||||
},
|
||||
"next_previous": {
|
||||
"auto_hide": "Automatically hide next & previous controls",
|
||||
"editor_label": "Next & Previous",
|
||||
"size": "Next & previous control size in pixels",
|
||||
"style": "Next & previous control style",
|
||||
@@ -310,6 +315,8 @@
|
||||
"inactive": "Only when not interacting"
|
||||
},
|
||||
"media_action_conditions": {
|
||||
"call_mute": "On call end",
|
||||
"call_unmute": "On call start",
|
||||
"hidden": "On browser/tab hiding",
|
||||
"microphone_mute": "On microphone mute",
|
||||
"microphone_unmute": "On microphone unmute",
|
||||
@@ -387,6 +394,17 @@
|
||||
"auto_play": "Automatically play live cameras",
|
||||
"auto_unmute": "Automatically unmute live cameras",
|
||||
"controls": {
|
||||
"call": {
|
||||
"button_size": "Call control button size",
|
||||
"editor_label": "Two-way audio call",
|
||||
"end": "End 2-way audio call",
|
||||
"lock": "Lock UI during an active call",
|
||||
"mute_audio": "Mute audio",
|
||||
"mute_microphone": "Mute microphone",
|
||||
"start": "Start 2-way audio call",
|
||||
"unmute_audio": "Unmute audio",
|
||||
"unmute_microphone": "Unmute microphone"
|
||||
},
|
||||
"editor_label": "Live Controls",
|
||||
"ptz": {
|
||||
"editor_label": "PTZ",
|
||||
@@ -427,7 +445,6 @@
|
||||
"auto_unmute": "Automatically unmute the microphone",
|
||||
"disconnect_seconds": "Seconds before disconnecting microphone (0=never)",
|
||||
"editor_label": "Microphone",
|
||||
"lock": "Lock the UI while the microphone is unmuted",
|
||||
"mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio"
|
||||
},
|
||||
"preload": "Preload live view in the background",
|
||||
@@ -461,6 +478,7 @@
|
||||
"right": "Aligned to the right",
|
||||
"top": "Aligned to the top"
|
||||
},
|
||||
"auto_hide": "Conditions under which the menu auto-hides",
|
||||
"button_size": "Menu button size in pixels",
|
||||
"buttons": {
|
||||
"alignment": "Button alignment",
|
||||
@@ -468,6 +486,7 @@
|
||||
"matching": "Matching the menu alignment",
|
||||
"opposing": "Opposing the menu alignment"
|
||||
},
|
||||
"call": "Call / Two-way audio",
|
||||
"camera_ui": "Camera user interface",
|
||||
"cameras": "Cameras",
|
||||
"clips": "Clips",
|
||||
@@ -555,6 +574,7 @@
|
||||
}
|
||||
},
|
||||
"status_bar": {
|
||||
"auto_hide": "Conditions under which the status bar auto-hides",
|
||||
"height": "Status bar height in pixels",
|
||||
"items": {
|
||||
"enabled": "Item enabled",
|
||||
@@ -726,6 +746,11 @@
|
||||
"error": {
|
||||
"awaiting_live": "Waiting for live stream to load...",
|
||||
"awaiting_media": "Waiting for media to load",
|
||||
"call_unavailable_heading": "Two-way audio unavailable",
|
||||
"call_invalid_target": "The requested camera or stream is not available to call.",
|
||||
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
|
||||
"call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).",
|
||||
"call_no_two_way_audio": "This camera does not support two-way audio.",
|
||||
"camera_initialization": "Camera initialization failed",
|
||||
"camera_initialization_reolink": "Could not initialize Reolink camera",
|
||||
"configuration": "Check configuration",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
@use './z-index.scss' as *;
|
||||
@use './pop-animation.scss' as *;
|
||||
|
||||
:host {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 16px;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
z-index: $z-index-call-controls;
|
||||
|
||||
--advanced-camera-card-call-controls-button-size: 40px;
|
||||
--ha-icon-button-size: var(--advanced-camera-card-call-controls-button-size);
|
||||
--mdc-icon-size: calc(var(--ha-icon-button-size) / 2);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 7px;
|
||||
border-radius: var(--advanced-camera-card-button-border-radius);
|
||||
background: var(--advanced-camera-card-call-controls-background);
|
||||
box-shadow: var(
|
||||
--advanced-camera-card-box-shadow-override,
|
||||
0 10px 30px rgba(0, 0, 0, 0.25)
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
@include pop-in;
|
||||
}
|
||||
|
||||
.panel.exiting {
|
||||
@include pop-out;
|
||||
|
||||
// The pill lingers in the DOM for the exit animation; stop it taking clicks
|
||||
// so a stale hangup/mute can't fire after the call has already ended.
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
ha-icon-button {
|
||||
color: var(--advanced-camera-card-button-color);
|
||||
background: var(--advanced-camera-card-button-background);
|
||||
border-radius: var(--advanced-camera-card-button-border-radius);
|
||||
|
||||
&.critical {
|
||||
color: var(--advanced-camera-card-call-controls-critical-color);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
@use './z-index.scss' as *;
|
||||
|
||||
:host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
// Picture elements always render above view content (media, gallery,
|
||||
// thumbnails) regardless of any z-index those surfaces use internally.
|
||||
z-index: $z-index-elements;
|
||||
|
||||
// Don't let elements overflow.
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
+26
-3
@@ -2,13 +2,13 @@
|
||||
|
||||
@keyframes warning-pulse {
|
||||
0% {
|
||||
border: solid 2px var(--trigger-border-color-base);
|
||||
border-color: var(--trigger-border-color-base);
|
||||
}
|
||||
50% {
|
||||
border: solid 2px var(--trigger-border-color);
|
||||
border-color: var(--trigger-border-color);
|
||||
}
|
||||
100% {
|
||||
border: solid 2px var(--trigger-border-color-base);
|
||||
border-color: var(--trigger-border-color-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,34 @@ advanced-camera-card-live-carousel {
|
||||
--advanced-camera-card-trigger-border-color-base,
|
||||
black
|
||||
);
|
||||
|
||||
transition: border-color 0.3s ease-out;
|
||||
}
|
||||
|
||||
// `:host >` matches the carousel only in single display mode (in grid mode it
|
||||
// is nested inside `media-grid`). The single-mode carousel has no border of its
|
||||
// own, so reserve a transparent one so border changes (e.g. trigger) only
|
||||
// recolour. In grid mode the border is supplied (already width-reserved) by
|
||||
// `media-grid`.
|
||||
:host > advanced-camera-card-live-carousel {
|
||||
box-sizing: border-box;
|
||||
border: solid 2px transparent;
|
||||
}
|
||||
|
||||
advanced-camera-card-live-carousel[triggered] {
|
||||
animation: warning-pulse 5s infinite;
|
||||
}
|
||||
// The `:host` prefix raises specificity to (0,2,1), above `media-grid`'s
|
||||
// `::slotted([selected])` border (0,1,1). In grid mode that rule lives in an
|
||||
// inner shadow tree and would otherwise win the cross-tree cascade tie,
|
||||
// leaving the transmitting border the wrong colour. `[triggered]` needs no
|
||||
// such treatment — animated values always beat the regular cascade.
|
||||
:host advanced-camera-card-live-carousel[transmitting] {
|
||||
// `animation: none` cancels any concurrent trigger pulse to ensure
|
||||
// transmitting takes precedence.
|
||||
animation: none;
|
||||
border-color: var(--advanced-camera-card-transmitting-border-color);
|
||||
}
|
||||
advanced-camera-card-live-carousel[selected] {
|
||||
--trigger-border-color-base: var(
|
||||
--advanced-camera-card-trigger-border-color-base,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@use './button.scss';
|
||||
@use 'locked.scss';
|
||||
|
||||
:host {
|
||||
--advanced-camera-card-next-prev-size: 48px;
|
||||
@@ -24,6 +23,17 @@
|
||||
right: var(--advanced-camera-card-right-position);
|
||||
}
|
||||
|
||||
// Dim while locked. Deliberately applied to `.controls` rather than `:host`
|
||||
// (as the shared `locked.scss` does): `opacity` on the host would create a
|
||||
// stacking context that traps `.controls`'s `z-index`, dropping the control
|
||||
// behind the media whenever it is DOM-ordered before it (the left control).
|
||||
// `.controls` is already positioned and z-indexed, so dimming it here leaves
|
||||
// its stacking context intact.
|
||||
:host([locked]) .controls {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls.icons {
|
||||
top: calc(50% - (var(--advanced-camera-card-next-prev-size) / 2));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use './z-index.scss' as *;
|
||||
@use './pop-animation.scss' as *;
|
||||
@use './notification-common.scss';
|
||||
|
||||
:host {
|
||||
@@ -62,45 +63,11 @@
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
// Entry animation (auto-plays on render)
|
||||
animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
@include pop-in;
|
||||
}
|
||||
|
||||
// Exit animation
|
||||
.notification.exiting {
|
||||
animation: slideDown 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(3px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
@include pop-out;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Shared "pop" enter/exit animation for overlays.
|
||||
//
|
||||
// `@include pop-in` auto-plays an entrance on render. `@include pop-out` —
|
||||
// typically guarded by an `.exiting` class — plays the matching exit; it is
|
||||
// named `pop-out` so an `animationend` handler can detect exit completion and
|
||||
// unmount the element.
|
||||
|
||||
@mixin pop-in {
|
||||
animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
@mixin pop-out {
|
||||
animation: pop-out 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translateY(-6px) scale(1.02);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(3px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,20 @@
|
||||
// elements render as pills/capsules rather than ovals.
|
||||
--advanced-camera-card-button-border-radius: 100vmax;
|
||||
|
||||
/***************
|
||||
* Call controls
|
||||
***************/
|
||||
|
||||
// The background of the call controls.
|
||||
--advanced-camera-card-call-controls-background: var(
|
||||
--advanced-camera-card-control-background-transparent
|
||||
);
|
||||
|
||||
// The color of the call controls end-call (critical) button.
|
||||
--advanced-camera-card-call-controls-critical-color: var(
|
||||
--advanced-camera-card-warning-color
|
||||
);
|
||||
|
||||
/******
|
||||
* Menu
|
||||
******/
|
||||
@@ -234,6 +248,13 @@
|
||||
--advanced-camera-card-trigger-border-color: var(--advanced-camera-card-warning-color);
|
||||
--advanced-camera-card-trigger-border-color-base: unset;
|
||||
|
||||
/**************
|
||||
* Transmitting
|
||||
**************/
|
||||
--advanced-camera-card-transmitting-border-color: var(
|
||||
--advanced-camera-card-warning-color
|
||||
);
|
||||
|
||||
/*****
|
||||
* Grid
|
||||
******/
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
box-sizing: border-box;
|
||||
gap: 2px;
|
||||
|
||||
// Ensure control icons are relative to the thumbnail.
|
||||
// Position control icons relative to the thumbnail, and `isolation: isolate`
|
||||
// to keep their `z-index` scoped here. Without a stacking context an
|
||||
// unhovered gallery tile (which has no `transform`) lets its feature icons'
|
||||
// z-index leak into the card's stacking context and paint over the `elements`
|
||||
// overlay.
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
|
||||
transition: transform 0.2s linear;
|
||||
|
||||
@@ -18,3 +18,11 @@ $z-index-menu: 4;
|
||||
$z-index-notification: 4;
|
||||
$z-index-drawer: 3;
|
||||
$z-index-status-bar: 2;
|
||||
|
||||
// The call controls overlay sits above the live media (a peer of the status
|
||||
// bar) but below the menu, drawer and other card chrome.
|
||||
$z-index-call-controls: 2;
|
||||
|
||||
// Picture elements overlay every view's content (which paints at z-index
|
||||
// `auto`), so they always win against media/gallery/thumbnail surfaces.
|
||||
$z-index-elements: 1;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CardActionsAPI } from '../card-controller/types.js';
|
||||
import { ZoomSettingsBase } from '../components-lib/zoom/types.js';
|
||||
import { CallEndActionConfig } from '../config/schema/actions/custom/call-end.js';
|
||||
import { CallStartActionConfig } from '../config/schema/actions/custom/call-start.js';
|
||||
import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js';
|
||||
import { DisplayModeActionConfig } from '../config/schema/actions/custom/display-mode.js';
|
||||
import {
|
||||
@@ -272,6 +274,30 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig
|
||||
};
|
||||
}
|
||||
|
||||
export function createCallStartAction(
|
||||
camera?: string,
|
||||
stream?: string,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
},
|
||||
): CallStartActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_start',
|
||||
...(camera && { camera }),
|
||||
...(stream && { stream }),
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCallEndAction(options?: { cardID?: string }): CallEndActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'call_end',
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createNotificationAction(
|
||||
notification: Notification,
|
||||
options?: {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { POP_OUT_ANIMATION_NAME } from '../const.js';
|
||||
|
||||
/**
|
||||
* Whether `ev` marks the end of a pop-out (exit) animation on the element the
|
||||
* handler is bound to.
|
||||
*
|
||||
* `animationend` bubbles and `pop-out` is a shared keyframe name, so an event
|
||||
* originating on a descendant that uses the same animation is excluded by
|
||||
* requiring the animation to have run on the listening element itself.
|
||||
*/
|
||||
export function hasPopOutAnimationEnded(
|
||||
// Only the fields actually read are required, rather than a full
|
||||
// `AnimationEvent`. jsdom has no `AnimationEvent` constructor, so this lets
|
||||
// tests pass a plain object instead of mocking the event.
|
||||
ev: Pick<AnimationEvent, 'target' | 'currentTarget' | 'animationName'>,
|
||||
): boolean {
|
||||
return ev.target === ev.currentTarget && ev.animationName === POP_OUT_ANIMATION_NAME;
|
||||
}
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { PTZAction } from '../config/schema/actions/custom/ptz';
|
||||
import { PTZCapabilities } from '../types';
|
||||
import { getStreamCameraID } from '../view/substream';
|
||||
import { getViewTargetID } from '../view/target-id';
|
||||
import { View } from '../view/view';
|
||||
import { getStreamCameraID } from './substream';
|
||||
|
||||
export type PTZType = 'digital' | 'ptz';
|
||||
interface PTZTarget {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { View } from '../view/view';
|
||||
|
||||
/**
|
||||
* Get the effective camera ID for streaming, considering substream overrides.
|
||||
* Returns null if the view has no camera.
|
||||
*/
|
||||
export const getStreamCameraID = (
|
||||
view: View,
|
||||
cameraID?: string | null,
|
||||
): string | null => {
|
||||
const baseCameraID = cameraID ?? view.camera;
|
||||
if (!baseCameraID) {
|
||||
return null;
|
||||
}
|
||||
return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID;
|
||||
};
|
||||
|
||||
export const hasSubstream = (view: View): boolean => {
|
||||
if (!view.camera) {
|
||||
return false;
|
||||
}
|
||||
return getStreamCameraID(view) !== view.camera;
|
||||
};
|
||||
|
||||
export const setSubstream = (view: View, substreamID: string): void => {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
|
||||
overrides.set(view.camera, substreamID);
|
||||
view.mergeInContext({
|
||||
live: { overrides: overrides },
|
||||
});
|
||||
};
|
||||
|
||||
export const removeSubstream = (view: View): void => {
|
||||
if (!view.camera) {
|
||||
return;
|
||||
}
|
||||
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
|
||||
if (overrides && overrides.has(view.camera)) {
|
||||
view.context?.live?.overrides?.delete(view.camera);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { View } from './view';
|
||||
|
||||
// Resolves the engaged stream for a camera: its substream override if one is
|
||||
// set in `live.overrides`, otherwise the camera itself. `cameraID` defaults to
|
||||
// the selected camera. The write path is `SubstreamViewModifier`.
|
||||
export const getStreamCameraID = (
|
||||
view: View,
|
||||
cameraID?: string | null,
|
||||
): string | null => {
|
||||
const baseCameraID = cameraID ?? view.camera;
|
||||
if (!baseCameraID) {
|
||||
return null;
|
||||
}
|
||||
return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID;
|
||||
};
|
||||
|
||||
export const hasSubstream = (view: View): boolean => {
|
||||
if (!view.camera) {
|
||||
return false;
|
||||
}
|
||||
return getStreamCameraID(view) !== view.camera;
|
||||
};
|
||||
Reference in New Issue
Block a user