feat: Add support for inbound "calls" from triggers (#2500)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent f4c686c298
commit 15e335a647
39 changed files with 2915 additions and 133 deletions
+31 -1
View File
@@ -56,9 +56,35 @@ live:
``` ```
| Option | Default | Description | | Option | Default | Description |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `button_size` | `40` | The size of the call control buttons in pixels. Must be >= `20`. | | `button_size` | `40` | The size of the call control buttons in pixels. Must be >= `20`. |
| `lock` | `true` | Whether to lock the rest of the card controls/actions while a call is in progress. Prevents an accidental tap, swipe or action mid-call. Set to `false` to allow interactions regardless of call state. | | `lock` | `true` | Whether to lock the rest of the card controls/actions while a call is in progress. Prevents an accidental tap, swipe or action mid-call. Set to `false` to allow interactions regardless of call state. |
| `ringtone` | | The audible chime played while an inbound call (e.g. one started by [`view.triggers.actions.trigger: call`](./view.md?id=trigger-action-configuration)) is ringing. Stops as soon as the microphone is un-muted (the "answer" gesture) or the call ends. Manual calls never ring. See [`ringtone`](#ringtone). |
| `unanswered_timeout_seconds` | `60` | The number of seconds an inbound call may ring unanswered before it is automatically ended. The timer is cancelled the moment the microphone is un-muted. Set to `0` to disable the timeout. |
> [!NOTE] Browser autoplay restrictions may prevent the ringtone from playing
> until the page has received a user gesture (e.g. a tap or click). When that
> happens no sound is produced and no error is shown. The underlying behavior is
> documented in [MDN's Autoplay
> guide](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide).
#### `ringtone`
Configures the chime played while an inbound call is ringing.
```yaml
live:
controls:
call:
ringtone:
# [...]
```
| Option | Default | Description |
| -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `chime` | Which ringtone to play. `chime` is the default two-note doorbell. `westminster` plays the four-note Westminster Quarters phrase. `arpeggio` plays three quick descending plucks. `melody` plays a I-V-I chord cadence. `custom` plays an audio file at `url` (e.g. an `.mp3` or any browser-supported URL). `none` disables the ringtone entirely (equivalent to leaving the call silent until answered). |
| `url` | | Path to an audio file to use as the ringtone. Only honored when `type` is `custom`; ignored otherwise. If `type` is `custom` but no `url` is provided, no ringtone plays. |
| `repeat` | `0` | Number of times the ringtone is played per inbound call. `0` loops indefinitely until the call is answered or ended. |
### `next_previous` ### `next_previous`
@@ -234,6 +260,10 @@ live:
call: call:
button_size: 40 button_size: 40
lock: true lock: true
ringtone:
type: chime
repeat: 0
unanswered_timeout_seconds: 60
next_previous: next_previous:
auto_hide: auto_hide:
- call - call
+3 -3
View File
@@ -163,10 +163,10 @@ human interaction with the card; this behavior can be configured via the
### Trigger action configuration ### Trigger action configuration
| Option | Default | Description | | Option | Default | Description |
| ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interaction_mode` | `inactive` | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card _has_ had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. | | `interaction_mode` | `inactive` | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card _has_ had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. |
| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip`, `snapshot`, `review`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. | | `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip`, `snapshot`, `review`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `call` a two-way-audio call is automatically started on the triggered camera. If set to `none` no action is taken. |
| `untrigger` | `none` | If set to `default` the the default view of the card will be reloaded. If set to `none` no action will be taken. | | `untrigger` | `none` | If set to `default` the default view of the card will be reloaded. If set to `call` any unanswered inbound call started by the matching `call` trigger action is ended (calls already answered persist and must be ended manually). If set to `none` no action will be taken. |
## Supported views ## Supported views
+40
View File
@@ -359,6 +359,46 @@ elements:
![Doorbell example](images/doorbell-example.gif 'Doorbell example :size=400') ![Doorbell example](images/doorbell-example.gif 'Doorbell example :size=400')
### Inbound call on doorbell press
This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn an dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard.
The Frigate camera's stock event triggers (`occupancy`, `motion`, `events`) are explicitly turned off so casual motion doesn't make the card ring — only an actual doorbell press does.
```yaml
type: custom:advanced-camera-card
cameras:
- camera_entity: camera.front_door
live_provider: go2rtc
go2rtc:
modes:
- webrtc
triggers:
occupancy: false
motion: false
events: []
entities:
- switch.door_bell
view:
default: live
triggers:
show_trigger_status: true
# Keeps the trigger "active" this long after the switch goes off.
# A doorbell button typically gives a brief ON pulse, so the chime
# would stop almost immediately if this were small -- this value
# is effectively how long the chime keeps ringing after the press.
untrigger_delay_seconds: 30
actions:
# Call when triggered.
trigger: call
# On release (after the untrigger_delay): end the call if it is
# still ringing. An answered call survives this and must be ended
# manually.
untrigger: call
# Ring even when somebody is actively using the card.
interaction_mode: all
```
## Events from other cameras ## Events from other cameras
`dependencies.cameras` allows events/recordings for other cameras to be shown `dependencies.cameras` allows events/recordings for other cameras to be shown
@@ -6,6 +6,9 @@ export class CallStartAction extends AdvancedCameraCardAction<CallStartActionCon
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
await api.getCallManager().start(this._action.camera, this._action.stream); await api.getCallManager().start({
cameraID: this._action.camera,
streamID: this._action.stream,
});
} }
} }
+180 -47
View File
@@ -1,22 +1,29 @@
import { createNotificationFromText } from '../../components-lib/notification/factory'; import { createNotificationFromText } from '../../components-lib/notification/factory';
import { ConditionStateChange } from '../../conditions/types'; import { ConditionStateChange } from '../../conditions/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { Timer } from '../../utils/timer';
import { getStreamCameraID } from '../../view/substream'; import { getStreamCameraID } from '../../view/substream';
import { View } from '../../view/view'; import { View } from '../../view/view';
import { CardCallAPI } from '../types'; import { CardCallAPI } from '../types';
import { SubstreamViewModifier } from '../view/modifiers/substream'; import { SubstreamViewModifier } from '../view/modifiers/substream';
import { Ringtone } from './ringtone';
import { CallSession } from './types'; import { CallSession } from './types';
export class CallManager { export class CallManager {
private _api: CardCallAPI; private _api: CardCallAPI;
private _call: CallSession | null = null; private _call: CallSession | null = null;
private _ringtone = new Ringtone();
private _unansweredTimer = new Timer();
constructor(api: CardCallAPI) { constructor(api: CardCallAPI) {
this._api = api; this._api = api;
}
// A call runs on the live view of a specific camera. Observe the public initialize(): void {
// condition state so the call can be ended if the view, camera or engaged // A call runs on the live view of a specific camera. The listener watches
// substream moves off what the call started on. // condition state so the call can be ended when the view, camera, or
// engaged substream moves off what the call started on -- and so an inbound
// call can register the user's "answer" (microphone un-mute).
this._api.getConditionStateManager().addListener(this._handleConditionStateChange); this._api.getConditionStateManager().addListener(this._handleConditionStateChange);
} }
@@ -36,12 +43,19 @@ export class CallManager {
// Lifecycle // Lifecycle
// ========================================================================= // =========================================================================
public async start(cameraID?: string, streamID?: string): Promise<void> { // Returns true iff the requested call is active after this returns, false
// otherwise.
public async start(options?: {
cameraID?: string;
streamID?: string;
inbound?: boolean;
}): Promise<boolean> {
const inbound = !!options?.inbound;
const view = this._api.getViewManager().getView(); const view = this._api.getViewManager().getView();
const parentID = cameraID ?? view?.camera; const parentID = options?.cameraID ?? view?.camera;
if (!view || !parentID) { if (!view || !parentID) {
return; return false;
} }
if ( if (
@@ -51,15 +65,15 @@ export class CallManager {
.getCameraIDsWithCapability('live') .getCameraIDsWithCapability('live')
.has(parentID) .has(parentID)
) { ) {
this._notifyError('error.call_invalid_target'); this._notifyError('error.call_invalid_target', inbound);
return; return false;
} }
const targetID = streamID const targetID = options?.streamID
? this._validateStream(parentID, streamID) ? this._validateStream(parentID, options.streamID, inbound)
: this._pickDefaultTarget(view, parentID); : this._pickDefaultTarget(view, parentID, inbound);
if (!targetID) { if (!targetID) {
return; return false;
} }
// `callCameraID` is the substream carrying the call audio -- absent when // `callCameraID` is the substream carrying the call audio -- absent when
@@ -72,17 +86,17 @@ export class CallManager {
existingCall.cameraID === parentID && existingCall.cameraID === parentID &&
existingCall.callCameraID === callCameraID existingCall.callCameraID === callCameraID
) { ) {
// This exact call (same parent camera and stream) is already running; a // This exact call (same parent camera and stream) is already running --
// repeat request must not disrupt it. // the caller has what they asked for.
return; return true;
} }
if (!this._microphonePreflight()) { if (!this._microphonePreflight(inbound)) {
return; return false;
} }
if (!(await this._connectMicrophone())) { if (!(await this._connectMicrophone(inbound))) {
return; return false;
} }
// Store the previous view so it can be restored later. A call superseding // Store the previous view so it can be restored later. A call superseding
@@ -94,17 +108,26 @@ export class CallManager {
const needsNavigation = !view.is('live') || view.camera !== parentID; const needsNavigation = !view.is('live') || view.camera !== parentID;
// Any other call in progress is superseded. Ended here -- after the // An inbound call must not yank the user away from a call they care about.
// preflight passes -- so a failed preflight leaves the existing call // Skip the new start request if the existing call is either manual
// intact. // (user-initiated) or already answered (user engaged). Newer inbound rings
// still replace older unanswered ones.
if (existingCall) { if (existingCall) {
if (inbound && (existingCall.answered || !existingCall.inbound)) {
return false;
}
this._end(false); this._end(false);
} }
// An already-unmuted mic is treated as "answered" for an inbound call.
const answered = inbound && !this._api.getMicrophoneManager().isMuted();
this._call = { this._call = {
cameraID: parentID, cameraID: parentID,
...(callCameraID && { callCameraID }), ...(callCameraID && { callCameraID }),
previousView, previousView,
inbound,
answered,
}; };
this._api.getViewManager().setViewByParameters({ this._api.getViewManager().setViewByParameters({
@@ -115,12 +138,79 @@ export class CallManager {
force: true, force: true,
}); });
this._api.getConditionStateManager().setState({ call: true }); this._api.getConditionStateManager().setState({ call: true });
// Re-read the session as the listeners triggered by `call: true` may have
// already have changed the state.
const call = this._call;
if (!call) {
return false;
} }
// Ends the call and returns to the view that was showing before // Ring only if still unanswered.
// `call_start` -- the user-facing `call_end`. const callConfig = this._api.getConfigManager().getConfig()?.live.controls.call;
public end(): void { const ringtoneConfig = callConfig?.ringtone;
this._end(true); if (
call.inbound &&
!call.answered &&
ringtoneConfig &&
ringtoneConfig.type !== 'none'
) {
this._ringtone.start(ringtoneConfig);
}
// Arm the unanswered-call timeout: if the inbound call rings for this long
// without being answered, end it.
const timeoutSeconds = callConfig?.unanswered_timeout_seconds ?? 0;
if (call.inbound && !call.answered && timeoutSeconds > 0) {
this._unansweredTimer.start(timeoutSeconds, () => this.end());
}
return true;
}
// Ends the call and returns to the pre-call view. Returns true iff a call was
// actually ended (false when there's no active call).
public end(): boolean {
return this._end(true);
}
// Ends the active call iff every supplied predicate matches the session.
// Returns true iff a call was actually ended.
public endIf(options: {
cameraID?: string;
inbound?: boolean;
answered?: boolean;
}): boolean {
if (!this._call) {
return false;
}
if (options.cameraID !== undefined && this._call.cameraID !== options.cameraID) {
return false;
}
if (options.inbound !== undefined && this._call.inbound !== options.inbound) {
return false;
}
if (options.answered !== undefined && this._call.answered !== options.answered) {
return false;
}
return this.end();
}
// Tears down everything `initialize()` set up: stops any in-flight ringtone
// and unanswered timer, drops the active call session, clears the call
// condition state, and de-registers the condition-state listener. Driven by
// the card element lifecycle: called from `elementDisconnected`.
//
// Safe to re-initialize afterwards via `initialize()`.
public uninitialize(): void {
this._ringtone.stop();
this._unansweredTimer.stop();
if (this._call) {
this._call = null;
this._api.getConditionStateManager().setState({ call: false });
}
this._api
.getConditionStateManager()
.removeListener(this._handleConditionStateChange);
} }
// `restoreView` navigates back to the pre-call view -- the symmetric // `restoreView` navigates back to the pre-call view -- the symmetric
@@ -128,13 +218,18 @@ export class CallManager {
// is `false` for auto-ends (navigating away, camera/substream change), where // is `false` for auto-ends (navigating away, camera/substream change), where
// the user has already chosen a destination and the pre-call view is // 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. // deliberately not reinstated; only the manager's own auto-end paths pass it.
private _end(restoreView: boolean): void { // Returns true iff a call was actually ended.
private _end(restoreView: boolean): boolean {
if (!this._call) { if (!this._call) {
return; return false;
} }
const call = this._call; const call = this._call;
const previousView = call.previousView; const previousView = call.previousView;
// Silence any ringtone before the navigation.
this._ringtone.stop();
this._unansweredTimer.stop();
// Clear the session first: ending the call dispatches a view change, and // Clear the session first: ending the call dispatches a view change, and
// the resulting condition-state change must not see this (now-ending) call // the resulting condition-state change must not see this (now-ending) call
// and recurse. // and recurse.
@@ -171,17 +266,43 @@ export class CallManager {
}); });
} }
this._api.getConditionStateManager().setState({ call: false }); this._api.getConditionStateManager().setState({ call: false });
return true;
} }
// End the call once it can no longer be conducted from where it started: the // Watches condition state for two transitions during an active call:
// view leaves `live` (the call overlay exists only there, so the call would //
// otherwise be stranded with no controls), the selected camera changes, or // 1. End the call once it can no longer be conducted from where it started
// the engaged substream moves off the call's audio source. Covers navigation // (e.g. view change). Only react to changes in view/camera/substream
// and `live_substream_*` actions taken while `live.controls.call.lock` is // themselves -- not to unrelated state updates (e.g. `mediaLoadedInfo`)
// disabled, as well as any forced view change. // that may arrive before the view-manager's own state update.
//
// 2. Register an inbound call as "answered" the first time the microphone
// un-mutes during the call -- a muted->unmuted transition. Idempotent:
// once answered we never flip back, so re-muting later does not undo it.
// Answering also silences the ringtone.
private _handleConditionStateChange = (stateChange: ConditionStateChange): void => { private _handleConditionStateChange = (stateChange: ConditionStateChange): void => {
if (!this._call) {
return;
}
if ( if (
this._call && this._call.inbound &&
!this._call.answered &&
stateChange.change.microphone &&
stateChange.new.microphone?.muted === false &&
stateChange.old.microphone?.muted !== false
) {
this._call.answered = true;
this._ringtone.stop();
this._unansweredTimer.stop();
}
const viewRelevantChange =
stateChange.change.view !== undefined ||
stateChange.change.camera !== undefined ||
stateChange.change.substreamID !== undefined;
if (
viewRelevantChange &&
(stateChange.new.view !== 'live' || (stateChange.new.view !== 'live' ||
stateChange.new.camera !== this._call.cameraID || stateChange.new.camera !== this._call.cameraID ||
stateChange.new.substreamID !== this._call.callCameraID) stateChange.new.substreamID !== this._call.callCameraID)
@@ -194,7 +315,11 @@ export class CallManager {
// Helpers // Helpers
// ========================================================================= // =========================================================================
private _notifyError(messageKey: string): void { private _notifyError(messageKey: string, inbound: boolean): void {
if (inbound) {
// Don't show errors on inbound calls.
return;
}
this._api.getNotificationManager().setNotification( this._api.getNotificationManager().setNotification(
createNotificationFromText(localize(messageKey), { createNotificationFromText(localize(messageKey), {
heading: { text: localize('error.call_unavailable_heading') }, heading: { text: localize('error.call_unavailable_heading') },
@@ -202,25 +327,25 @@ export class CallManager {
); );
} }
// Returns `true` to proceed, `false` to abort (with a notification already // Returns `true` to proceed, `false` to abort (with a notification surfaced
// surfaced). // unless `inbound` is set).
private _microphonePreflight(): boolean { private _microphonePreflight(inbound: boolean): boolean {
const microphoneManager = this._api.getMicrophoneManager(); const microphoneManager = this._api.getMicrophoneManager();
if (!microphoneManager.isSupported()) { if (!microphoneManager.isSupported()) {
this._notifyError('error.call_microphone_unsupported'); this._notifyError('error.call_microphone_unsupported', inbound);
return false; return false;
} }
if (microphoneManager.isForbidden()) { if (microphoneManager.isForbidden()) {
this._notifyError('error.call_microphone_forbidden'); this._notifyError('error.call_microphone_forbidden', inbound);
return false; return false;
} }
return true; return true;
} }
private async _connectMicrophone(): Promise<boolean> { private async _connectMicrophone(inbound: boolean): Promise<boolean> {
const microphoneManager = this._api.getMicrophoneManager(); const microphoneManager = this._api.getMicrophoneManager();
if (microphoneManager.isConnected()) { if (microphoneManager.isConnected()) {
return true; return true;
@@ -229,7 +354,7 @@ export class CallManager {
await microphoneManager.connect(); await microphoneManager.connect();
return true; return true;
} catch { } catch {
this._notifyError('error.call_microphone_forbidden'); this._notifyError('error.call_microphone_forbidden', inbound);
return false; return false;
} }
} }
@@ -243,13 +368,17 @@ export class CallManager {
// Validate an explicitly-requested call stream: it must be `cameraID` itself // Validate an explicitly-requested call stream: it must be `cameraID` itself
// or one of its 2-way-audio dependencies. // or one of its 2-way-audio dependencies.
private _validateStream(cameraID: string, streamID: string): string | null { private _validateStream(
cameraID: string,
streamID: string,
inbound: boolean,
): string | null {
const eligibleCameraIDs = this._api const eligibleCameraIDs = this._api
.getCameraManager() .getCameraManager()
.getStore() .getStore()
.getAllDependentCameras(cameraID, '2-way-audio'); .getAllDependentCameras(cameraID, '2-way-audio');
if (!eligibleCameraIDs.has(streamID)) { if (!eligibleCameraIDs.has(streamID)) {
this._notifyError('error.call_invalid_target'); this._notifyError('error.call_invalid_target', inbound);
return null; return null;
} }
return streamID; return streamID;
@@ -259,7 +388,11 @@ export class CallManager {
// it's call-capable (keeps the user's substream selection intact). Else // 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 // fall back to the parent itself (if call-capable) or the first eligible
// dependency. Returns null + notification if neither path finds a target. // dependency. Returns null + notification if neither path finds a target.
private _pickDefaultTarget(view: View, parentID: string): string | null { private _pickDefaultTarget(
view: View,
parentID: string,
inbound: boolean,
): string | null {
const currentStream = getStreamCameraID(view, parentID); const currentStream = getStreamCameraID(view, parentID);
if (currentStream && this._hasCallCapability(currentStream)) { if (currentStream && this._hasCallCapability(currentStream)) {
return currentStream; return currentStream;
@@ -272,7 +405,7 @@ export class CallManager {
.getAllDependentCameras(parentID, '2-way-audio'), .getAllDependentCameras(parentID, '2-way-audio'),
]; ];
if (!candidates.length) { if (!candidates.length) {
this._notifyError('error.call_no_two_way_audio'); this._notifyError('error.call_no_two_way_audio', inbound);
return null; return null;
} }
return candidates[0]; return candidates[0];
+87
View File
@@ -0,0 +1,87 @@
import { RingtoneConfig } from '../../config/schema/live';
import { ArpeggioTone } from './tones/arpeggio';
import { ChimeTone } from './tones/chime';
import { CustomTone } from './tones/custom';
import { MelodyTone } from './tones/melody';
import { Tone } from './tones/types';
import { WestminsterTone } from './tones/westminster';
// Module-level singleton lock: only one `Ringtone` plays at a time across all
// card instances on the page. The HA dashboard can render multiple card
// instances simultaneously (e.g. dashboard card + editor preview, or the same
// card placed twice), all of which may independently react to the same trigger
// state change -- with no lock, every instance would start its own AudioContext
// and the audio would layer. First-to-start wins; subsequent `start()` calls
// from other holders are no-ops until the active one releases via `stop()`. The
// lock auto-recovers from a holder that forgot to release (e.g. a controller
// GC'd without disconnect cleanup) via the `isPlaying()` sweep below.
const sharedLock = new Set<Ringtone>();
export class Ringtone {
private _tone: Tone | null = null;
private readonly lock: Set<Ringtone>;
// The `lock` parameter defaults to the module-level singleton so production
// callers (`new Ringtone()`) get cross-instance coordination automatically.
// Test callers can pass `new Set()` per test to isolate state without
// touching a process-wide value.
constructor(lock: Set<Ringtone> = sharedLock) {
this.lock = lock;
}
public start(config: RingtoneConfig): void {
if (this._tone) {
return;
}
// Drop any stale holders before checking the lock.
for (const other of this.lock) {
if (!other.isPlaying()) {
this.lock.delete(other);
}
}
// Another tone is already ringing.
if (this.lock.size) {
return;
}
this._tone = this._createTone(config);
if (this._tone) {
this.lock.add(this);
this._tone.start(() => this._handleToneEnd());
}
}
public stop(): void {
this._tone?.stop();
this._tone = null;
this.lock.delete(this);
}
public isPlaying(): boolean {
return !!this._tone;
}
private _handleToneEnd(): void {
this._tone = null;
this.lock.delete(this);
}
private _createTone(config: RingtoneConfig): Tone | null {
switch (config.type) {
case 'chime':
return new ChimeTone(config.repeat);
case 'westminster':
return new WestminsterTone(config.repeat);
case 'arpeggio':
return new ArpeggioTone(config.repeat);
case 'melody':
return new MelodyTone(config.repeat);
case 'custom':
return config.url ? new CustomTone(config.url, config.repeat) : null;
case 'none':
return null;
}
}
}
@@ -0,0 +1,23 @@
import { BellStrikeOptions, BellTone } from './bell';
const PLUCK: BellStrikeOptions = {
sparklePeak: 0.05,
fundPeak: 0.13,
humPeak: 0.04,
sparkleDecay: 0.15,
fundDecay: 0.3,
humDecay: 0.5,
};
// Three quick descending notes -- G5, E5, C5 -- 0.25s apart. Shorter decays
// than the other bell tones since the arpeggio's character is lightness and
// pace.
export class ArpeggioTone extends BellTone {
protected _play(): void {
const t0 = this._currentTime;
this._strike(783.99, t0 + 0.0, PLUCK); // G5
this._strike(659.25, t0 + 0.25, PLUCK); // E5
this._strike(523.25, t0 + 0.5, PLUCK); // C5
this._scheduleNext(3);
}
}
+115
View File
@@ -0,0 +1,115 @@
import { Timer } from '../../../utils/timer';
import { RingtoneFinishedHandler, Tone, ToneEnvelope } from './types';
// Shared scaffolding for tones generated via the Web Audio API: owns the
// AudioContext lifecycle, the repeat timer, and the bell-shaped note envelope.
// Subclasses implement `_play()` to define one iteration of their pattern, and
// call `_scheduleNext()` to loop.
//
// `repeat` caps how many iterations are played per `start()`. `0` means loop
// indefinitely; otherwise the tone schedules a final no-op timer to let the
// last iteration's decay tail finish audibly, then fires `finishedHandler` and
// self-stops.
export abstract class GeneratedTone implements Tone {
private _context: AudioContext | null = null;
private _timer = new Timer();
private _finishedHandler: RingtoneFinishedHandler | null = null;
private readonly _repeat: number;
private _remaining = 0;
constructor(repeat: number) {
this._repeat = repeat;
}
public start(finishedHandler?: RingtoneFinishedHandler): void {
if (this._context) {
return;
}
try {
this._context = new AudioContext();
} catch {
this._context = null;
// Treat AudioContext construction failure as natural completion so the
// caller can release any lock it holds on our behalf -- otherwise the
// orchestrator can't tell silent failure from active playback.
finishedHandler?.();
return;
}
this._finishedHandler = finishedHandler ?? null;
this._remaining = this._repeat;
this._play();
}
public stop(): void {
this._timer.stop();
this._context?.close().catch(() => {});
this._context = null;
// Suppress any natural-completion callback -- the caller asked to stop.
this._finishedHandler = null;
}
// Current AudioContext time. Subclasses only read this from inside `_play()`
// where the context is always set.
protected get _currentTime(): number {
/* istanbul ignore next: _currentTime is only read by subclasses from
_play() during an active context -- @preserve */
return this._context?.currentTime ?? 0;
}
// Schedule the next iteration of `_play()`. Subclasses call this at the end
// of their pattern to loop. No-ops if the context has already been closed so
// a stopped tone can never re-arm its loop.
protected _scheduleNext(intervalSeconds: number): void {
/* istanbul ignore next: defensive guard against a subclass calling
_scheduleNext after stop() — JS single-threading makes this unreachable
from the existing subclasses -- @preserve */
if (!this._context) {
return;
}
if (this._repeat > 0 && --this._remaining <= 0) {
// Schedule one last wait for the decay tail.
this._timer.start(intervalSeconds, () => {
const finishedHandler = this._finishedHandler;
this.stop();
finishedHandler?.();
});
return;
}
this._timer.start(intervalSeconds, () => {
/* istanbul ignore next: Timer.stop() cancels pending callbacks, so this
re-entry guard is unreachable in practice -- @preserve */
if (!this._context) {
return;
}
this._play();
});
}
// Plays one note: a smooth tone that rises to peak volume and then fades.
protected _playNote(freq: number, when: number, envelope: ToneEnvelope): void {
/* istanbul ignore next: _playNote is only called by subclasses from
_play() during an active context -- @preserve */
if (!this._context) {
return;
}
const oscillator = this._context.createOscillator();
const gain = this._context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = freq;
oscillator.connect(gain);
gain.connect(this._context.destination);
gain.gain.setValueAtTime(0, when);
gain.gain.linearRampToValueAtTime(envelope.peak, when + envelope.attack);
gain.gain.setTargetAtTime(0, when + envelope.attack, envelope.decayTau);
oscillator.start(when);
oscillator.stop(when + envelope.hold);
}
// One iteration of the pattern. Implementations should call `_playNote(...)`
// for each note and finish with `_scheduleNext(...)` to loop.
protected abstract _play(): void;
}
+50
View File
@@ -0,0 +1,50 @@
import { GeneratedTone } from './base';
// Per-layer volume and decay tuning for a single bell strike. All fields are
// optional with sensible defaults; subclasses override only the layers they
// want to tune.
export interface BellStrikeOptions {
sparklePeak?: number;
fundPeak?: number;
humPeak?: number;
sparkleDecay?: number;
fundDecay?: number;
humDecay?: number;
}
// Base for tones whose pattern is a series of single-note bell strikes. Each
// strike stacks a sparkle (one octave above the fundamental), the
// fundamental, and a hum (one octave below), with differential decay --
// sparkle fades fastest, hum lingers -- for the natural bell evolution.
//
// Subclasses define the pattern by calling `_strike(freq, when, options)` at
// the right moments; this base handles the three-layer stacking.
export abstract class BellTone extends GeneratedTone {
protected _strike(freq: number, when: number, options?: BellStrikeOptions): void {
const sparklePeak = options?.sparklePeak ?? 0.06;
const fundPeak = options?.fundPeak ?? 0.14;
const humPeak = options?.humPeak ?? 0.05;
const sparkleDecay = options?.sparkleDecay ?? 0.3;
const fundDecay = options?.fundDecay ?? 0.6;
const humDecay = options?.humDecay ?? 1.0;
this._playNote(freq * 2, when, {
peak: sparklePeak,
attack: 0.005,
decayTau: sparkleDecay,
hold: sparkleDecay * 4,
});
this._playNote(freq, when, {
peak: fundPeak,
attack: 0.005,
decayTau: fundDecay,
hold: fundDecay * 4,
});
this._playNote(freq / 2, when, {
peak: humPeak,
attack: 0.005,
decayTau: humDecay,
hold: humDecay * 3,
});
}
}
+30
View File
@@ -0,0 +1,30 @@
import { BellTone } from './bell';
// A classic doorbell "DING DOOOOONG" -- two strikes, Eb5 down to B4 (a major
// third). Each strike is a bell stack: sparkle one octave above, fundamental,
// hum one octave below, with differential decay (sparkle fades fastest, hum
// lingers).
export class ChimeTone extends BellTone {
protected _play(): void {
const t0 = this._currentTime;
// DING -- Eb5.
this._strike(622.25, t0, {
sparklePeak: 0.1,
fundPeak: 0.22,
humPeak: 0.08,
sparkleDecay: 0.3,
fundDecay: 0.8,
humDecay: 1.2,
});
// DOOOOONG -- B4, louder and longer.
this._strike(493.88, t0 + 0.5, {
sparklePeak: 0.11,
fundPeak: 0.28,
humPeak: 0.1,
sparkleDecay: 0.5,
fundDecay: 1.3,
humDecay: 1.8,
});
this._scheduleNext(5);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { RingtoneFinishedHandler, Tone } from './types';
export class CustomTone implements Tone {
private _audio: HTMLAudioElement | null = null;
private _finishedHandler: RingtoneFinishedHandler | null = null;
private readonly _url: string;
private readonly _repeat: number;
private _remaining = 0;
constructor(url: string, repeat: number) {
this._url = url;
this._repeat = repeat;
}
public start(finishedHandler?: RingtoneFinishedHandler): void {
if (this._audio) {
return;
}
try {
this._audio = new Audio(this._url);
} catch {
this._audio = null;
// Treat constructor failure as natural completion so the caller can
// release any lock it holds on our behalf.
finishedHandler?.();
return;
}
this._finishedHandler = finishedHandler ?? null;
if (this._repeat === 0) {
this._audio.loop = true;
} else {
this._remaining = this._repeat;
this._audio.addEventListener('ended', this._handleEnded);
}
this._playAudio();
}
public stop(): void {
if (this._audio) {
this._audio.removeEventListener('ended', this._handleEnded);
this._audio.pause();
this._audio = null;
}
// Suppress any natural-completion callback -- the caller asked to stop.
this._finishedHandler = null;
}
private _handleEnded = (): void => {
/* istanbul ignore next: stop() removes this listener before nulling
_audio, so the handler can't fire with a null _audio -- @preserve */
if (!this._audio) {
return;
}
if (--this._remaining > 0) {
this._playAudio();
return;
}
this._finishNaturally();
};
private _playAudio(): void {
/* istanbul ignore next: callers (start, _handleEnded) only invoke
_playAudio when _audio is non-null -- @preserve */
if (!this._audio) {
return;
}
this._audio.currentTime = 0;
this._audio.play().catch(
// On `play()` rejection (autoplay block, network failure, decode error) no
// `ended` event will arrive, so signal completion ourselves to avoid leaks
// at the higher level (e.g. the ringtone lock).
() => this._finishNaturally(),
);
}
private _finishNaturally(): void {
const finishedHandler = this._finishedHandler;
this.stop();
finishedHandler?.();
}
}
+56
View File
@@ -0,0 +1,56 @@
import { GeneratedTone } from './base';
// A short melodic phrase: a I-V-I cadence in C major. Three triads played in
// sequence with a bell envelope -- each chord rings as the next begins, so
// the harmonies blend briefly before resolving home an octave higher. Each
// chord is framed bell-stack style with a sparkle an octave above the
// highest note and a hum an octave below the lowest, while the chord notes
// themselves are the fundamentals.
export class MelodyTone extends GeneratedTone {
protected _play(): void {
const t0 = this._currentTime;
// I -- C major: C5 + E5 + G5 (root C, sparkle G6, hum C4).
this._strike([523.25, 659.25, 783.99], 1567.98, 261.63, t0 + 0.0);
// V -- G major: G4 + B4 + D5 (root G, sparkle D6, hum G3).
this._strike([392.0, 493.88, 587.33], 1174.66, 196.0, t0 + 1.0);
// I -- C major higher: E5 + G5 + C6 (sparkle C7, hum E4), longer tail.
this._strike([659.25, 783.99, 1046.5], 2093.0, 329.63, t0 + 2.0, {
fundDecay: 0.9,
humDecay: 1.4,
});
this._scheduleNext(6);
}
private _strike(
chordFreqs: number[],
sparkleFreq: number,
humFreq: number,
when: number,
options?: { fundDecay?: number; humDecay?: number },
): void {
const fundDecay = options?.fundDecay ?? 0.6;
const humDecay = options?.humDecay ?? 1.1;
this._playNote(sparkleFreq, when, {
peak: 0.05,
attack: 0.005,
decayTau: 0.4,
hold: 1.6,
});
for (const freq of chordFreqs) {
this._playNote(freq, when, {
peak: 0.1,
attack: 0.005,
decayTau: fundDecay,
hold: fundDecay * 4,
});
}
this._playNote(humFreq, when, {
peak: 0.05,
attack: 0.005,
decayTau: humDecay,
hold: humDecay * 3,
});
}
}
+24
View File
@@ -0,0 +1,24 @@
export type RingtoneFinishedHandler = () => void;
// A playable inbound-call notification sound. Implementations may loop a
// generated pattern, play a single file, or do nothing -- `start()` is the only
// entry point and `stop()` halts whatever is in flight.
export interface Tone {
// Handler is called once when a tone exhausts its configured play count
// naturally (i.e. completes the last iteration's audible tail). Does NOT fire
// when `stop()` is invoked externally -- so callers can distinguish "tone
// finished playing" from "we asked it to stop".
start(finishedHandler?: RingtoneFinishedHandler): void;
stop(): void;
}
// A single note's volume shape over time: rises to `peak` over `attack`
// seconds, then fades. `decayTau` controls the fade speed (smaller = faster).
// `hold` sets the note's total duration -- pick a value large enough for the
// fade to be inaudible by the end.
export interface ToneEnvelope {
peak: number;
attack: number;
decayTau: number;
hold: number;
}
@@ -0,0 +1,15 @@
import { BellTone } from './bell';
// Westminster Quarters: the classic clock-tower four-note phrase
// (E5 - D5 - C5 - G4), played slow legato so each note rings into the next.
// The final G4 gets a longer tail to resolve the phrase.
export class WestminsterTone extends BellTone {
protected _play(): void {
const t0 = this._currentTime;
this._strike(659.25, t0 + 0.0); // E5
this._strike(587.33, t0 + 0.55); // D5
this._strike(523.25, t0 + 1.1); // C5
this._strike(392.0, t0 + 1.65, { fundDecay: 0.9, humDecay: 1.4 }); // G4, longer tail
this._scheduleNext(5);
}
}
+7
View File
@@ -12,4 +12,11 @@ export interface CallSession {
// The view from before the call started: a clone with `queryResults` dropped. // The view from before the call started: a clone with `queryResults` dropped.
// Used to undo the call when it ends. // Used to undo the call when it ends.
previousView: View; previousView: View;
// Marks the session as inbound (auto-started, typically by a trigger) rather
// than the result of an explicit user gesture.
inbound: boolean;
// Whether the use has "answered" an inbound call.
answered: boolean;
} }
@@ -70,6 +70,7 @@ export class CardElementManager {
this._api.getMicrophoneManager().initialize(); this._api.getMicrophoneManager().initialize();
this._api.getPIPManager().initialize(); this._api.getPIPManager().initialize();
this._api.getKeyboardStateManager().initialize(); this._api.getKeyboardStateManager().initialize();
this._api.getCallManager().initialize();
// These initializers are called when the config is updated, but on initial // These initializers are called when the config is updated, but on initial
// creation of the card hass is not yet available when the config is first // creation of the card hass is not yet available when the config is first
@@ -188,6 +189,8 @@ export class CardElementManager {
// correctly and triggers that changed while detached are picked up. // correctly and triggers that changed while detached are picked up.
// Reset trigger state first to stop stale timers and clear condition state. // Reset trigger state first to stop stale timers and clear condition state.
this._api.getTriggersManager().reset(); this._api.getTriggersManager().reset();
this._api.getCallManager().uninitialize();
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api this._api
.getInitializationManager() .getInitializationManager()
+30 -17
View File
@@ -181,28 +181,26 @@ export class TriggersManager {
private async _triggerAction(ev: CameraEvent): Promise<void> { private async _triggerAction(ev: CameraEvent): Promise<void> {
const config = this._api.getConfigManager().getConfig(); const config = this._api.getConfigManager().getConfig();
const triggerAction = config?.view?.triggers.actions.trigger; const triggersConfig = config?.view?.triggers;
const triggerAction = triggersConfig?.actions.trigger;
const defaultView = config?.view?.default; const defaultView = config?.view?.default;
// Early exit guard: If this is a high-fidelity event where we are certain // Skip the trigger action for a high-fidelity "no new media" event when
// about new media, don't take action unless it's to change to live (Frigate // the configured action would change to a non-live view (Frigate may pump
// engine may pump out events where there's no new media to show). Other // out such events). `live`, `call`, and default-with-live remain valid
// trigger actions (e.g. media, update) do not make sense without having // since they don't depend on media being available.
// some new media. const skipViewAction =
if (
ev.fidelity === 'high' && ev.fidelity === 'high' &&
!ev.snapshot && !ev.snapshot &&
!ev.clip && !ev.clip &&
!ev.review && !ev.review &&
!( !(
triggerAction === 'call' ||
triggerAction === 'live' || triggerAction === 'live' ||
(triggerAction === 'default' && defaultView === 'live') (triggerAction === 'default' && defaultView === 'live')
) );
) {
return;
}
if (this._hasAllowableInteractionStateForAction()) { if (this._hasAllowableInteractionStateForAction() && !skipViewAction) {
if (triggerAction === 'update') { if (triggerAction === 'update') {
await this._api.getViewManager().setViewByParametersWithNewQuery({ await this._api.getViewManager().setViewByParametersWithNewQuery({
queryExecutorOptions: { useCache: false }, queryExecutorOptions: { useCache: false },
@@ -220,6 +218,10 @@ export class TriggersManager {
camera: ev.cameraID, camera: ev.cameraID,
}, },
}); });
} else if (triggerAction === 'call') {
// Auto-call the triggered camera. `start()` itself handles the
// navigation to live -- it is idempotent if the view already matches.
await this._api.getCallManager().start({ cameraID: ev.cameraID, inbound: true });
} else if (ev.fidelity === 'high' && triggerAction === 'media') { } else if (ev.fidelity === 'high' && triggerAction === 'media') {
// Choose the most appropriate media view based on what's available. // Choose the most appropriate media view based on what's available.
// Priority: review > clip > snapshot // Priority: review > clip > snapshot
@@ -229,10 +231,10 @@ export class TriggersManager {
? 'clip' ? 'clip'
: ev.snapshot : ev.snapshot
? 'snapshot' ? 'snapshot'
: /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */ : /* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */
null; null;
/* istanbul ignore next: unreachable due to early exit guard above -- @preserve */ /* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */
if (view) { if (view) {
await this._api.getViewManager().setViewByParametersWithNewQuery({ await this._api.getViewManager().setViewByParametersWithNewQuery({
params: { params: {
@@ -255,7 +257,7 @@ export class TriggersManager {
}); });
} }
private async _executeUntriggerAction(): Promise<boolean> { private async _executeUntriggerAction(cameraID: string): Promise<boolean> {
const action = this._api.getConfigManager().getConfig()?.view?.triggers const action = this._api.getConfigManager().getConfig()?.view?.triggers
.actions.untrigger; .actions.untrigger;
@@ -263,8 +265,19 @@ export class TriggersManager {
return true; return true;
} }
if (this._hasAllowableInteractionStateForAction()) { if (!this._hasAllowableInteractionStateForAction()) {
return true;
}
switch (action) {
case 'default':
await this._api.getViewManager().setViewDefaultWithNewQuery(); await this._api.getViewManager().setViewDefaultWithNewQuery();
break;
case 'call':
// Triggers only end a call if the call is owned by this cameraID, if it
// was an inbound call and was not yet answered.
this._api.getCallManager().endIf({ cameraID, inbound: true, answered: false });
break;
} }
return true; return true;
} }
@@ -273,7 +286,7 @@ export class TriggersManager {
this._deleteUntriggerDelayTimer(cameraID); this._deleteUntriggerDelayTimer(cameraID);
this._deleteForceUntriggerTimer(cameraID); this._deleteForceUntriggerTimer(cameraID);
await this._executeUntriggerAction(); await this._executeUntriggerAction(cameraID);
this._deleteStateIfIdle(cameraID); this._deleteStateIfIdle(cameraID);
this._setConditionStateIfNecessary(); this._setConditionStateIfNecessary();
+3
View File
@@ -80,6 +80,7 @@ export interface CardAutomationsAPI {
export interface CardCallAPI { export interface CardCallAPI {
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getMicrophoneManager(): MicrophoneManager; getMicrophoneManager(): MicrophoneManager;
getNotificationManager(): NotificationManager; getNotificationManager(): NotificationManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
@@ -151,6 +152,7 @@ export interface CardDownloadAPI {
export interface CardElementAPI { export interface CardElementAPI {
getActionsManager(): ActionsManager; getActionsManager(): ActionsManager;
getCallManager(): CallManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
@@ -312,6 +314,7 @@ export interface CardStyleAPI {
} }
export interface CardTriggersAPI { export interface CardTriggersAPI {
getCallManager(): CallManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
+2 -1
View File
@@ -78,7 +78,6 @@ export class AdvancedCameraCardCarousel extends LitElement {
const destroyProperties = [ const destroyProperties = [
'direction', 'direction',
'dragEnabled',
'dragFree', 'dragFree',
'loop', 'loop',
'plugins', 'plugins',
@@ -87,6 +86,8 @@ export class AdvancedCameraCardCarousel extends LitElement {
if (destroyProperties.some((prop) => changedProps.has(prop))) { if (destroyProperties.some((prop) => changedProps.has(prop))) {
this._carousel?.destroy(); this._carousel?.destroy();
this._carousel = null; this._carousel = null;
} else if (changedProps.has('dragEnabled') && this._carousel) {
this._carousel.setDragEnabled(this.dragEnabled);
} }
} }
+26
View File
@@ -30,14 +30,40 @@ const microphoneConfigDefault = {
mute_after_microphone_mute_seconds: 60, mute_after_microphone_mute_seconds: 60,
}; };
const ringtoneConfigDefault = {
type: 'chime' as const,
repeat: 0,
};
const ringtoneConfigSchema = z.object({
type: z
.enum(['none', 'chime', 'westminster', 'arpeggio', 'melody', 'custom'])
.default(ringtoneConfigDefault.type),
// For `type` is `custom`, path to an audio file.
url: z.string().optional(),
// Number of times the ringtone plays per inbound call. `0` is indefinitely.
repeat: z.number().int().min(0).default(ringtoneConfigDefault.repeat),
});
export type RingtoneConfig = z.infer<typeof ringtoneConfigSchema>;
const callConfigDefault = { const callConfigDefault = {
button_size: 40, button_size: 40,
lock: true, lock: true,
ringtone: { ...ringtoneConfigDefault },
unanswered_timeout_seconds: 60,
}; };
const callConfigSchema = z.object({ const callConfigSchema = z.object({
button_size: z.number().min(BUTTON_SIZE_MIN).default(callConfigDefault.button_size), button_size: z.number().min(BUTTON_SIZE_MIN).default(callConfigDefault.button_size),
lock: z.boolean().default(callConfigDefault.lock), lock: z.boolean().default(callConfigDefault.lock),
ringtone: ringtoneConfigSchema.default(callConfigDefault.ringtone),
// Seconds an inbound call may ring unanswered before it is auto-ended.
unanswered_timeout_seconds: z
.number()
.min(0)
.default(callConfigDefault.unanswered_timeout_seconds),
}); });
const microphoneConfigSchema = z const microphoneConfigSchema = z
+2 -2
View File
@@ -90,10 +90,10 @@ export const triggersSchema = z.object({
.object({ .object({
interaction_mode: interactionModeSchema, interaction_mode: interactionModeSchema,
trigger: z trigger: z
.enum(['default', 'live', 'media', 'none', 'update']) .enum(['call', 'default', 'live', 'media', 'none', 'update'])
.default(viewConfigDefault.triggers.actions.trigger), .default(viewConfigDefault.triggers.actions.trigger),
untrigger: z untrigger: z
.enum(['default', 'none']) .enum(['call', 'default', 'none'])
.default(viewConfigDefault.triggers.actions.untrigger), .default(viewConfigDefault.triggers.actions.untrigger),
}) })
.default(viewConfigDefault.triggers.actions), .default(viewConfigDefault.triggers.actions),
+10 -2
View File
@@ -196,12 +196,12 @@ export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS =
export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS = export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const; `${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const; export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER = export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const; `${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER = export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const; `${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
const CONF_MEDIA_GALLERY = 'media_gallery' as const; const CONF_MEDIA_GALLERY = 'media_gallery' as const;
export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE = export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE =
@@ -294,6 +294,14 @@ export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as con
export const CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE = export const CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE =
`${CONF_LIVE}.controls.call.button_size` as const; `${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_CALL_LOCK = `${CONF_LIVE}.controls.call.lock` as const;
export const CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT =
`${CONF_LIVE}.controls.call.ringtone.repeat` as const;
export const CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE =
`${CONF_LIVE}.controls.call.ringtone.type` as const;
export const CONF_LIVE_CONTROLS_CALL_RINGTONE_URL =
`${CONF_LIVE}.controls.call.ringtone.url` as const;
export const CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS =
`${CONF_LIVE}.controls.call.unanswered_timeout_seconds` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE = export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE =
`${CONF_LIVE}.controls.next_previous.auto_hide` as const; `${CONF_LIVE}.controls.next_previous.auto_hide` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
+66
View File
@@ -123,6 +123,10 @@ import {
CONF_LIVE_CONTROLS_BUILTIN, CONF_LIVE_CONTROLS_BUILTIN,
CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE,
CONF_LIVE_CONTROLS_CALL_LOCK, CONF_LIVE_CONTROLS_CALL_LOCK,
CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT,
CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE,
CONF_LIVE_CONTROLS_CALL_RINGTONE_URL,
CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
@@ -967,6 +971,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
private _triggersActionsTrigger: EditorSelectOption[] = [ private _triggersActionsTrigger: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{
value: 'call',
label: localize('config.view.triggers.actions.triggers.call'),
},
{ {
value: 'default', value: 'default',
label: localize('config.view.triggers.actions.triggers.default'), label: localize('config.view.triggers.actions.triggers.default'),
@@ -987,6 +995,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
private _triggersActionsUntrigger: EditorSelectOption[] = [ private _triggersActionsUntrigger: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{
value: 'call',
label: localize('config.view.triggers.actions.untriggers.call'),
},
{ {
value: 'default', value: 'default',
label: localize('config.view.triggers.actions.untriggers.default'), label: localize('config.view.triggers.actions.untriggers.default'),
@@ -997,6 +1009,34 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
}, },
]; ];
private _callRingtoneTypes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'none',
label: localize('config.live.controls.call.ringtone.types.none'),
},
{
value: 'chime',
label: localize('config.live.controls.call.ringtone.types.chime'),
},
{
value: 'westminster',
label: localize('config.live.controls.call.ringtone.types.westminster'),
},
{
value: 'arpeggio',
label: localize('config.live.controls.call.ringtone.types.arpeggio'),
},
{
value: 'melody',
label: localize('config.live.controls.call.ringtone.types.melody'),
},
{
value: 'custom',
label: localize('config.live.controls.call.ringtone.types.custom'),
},
];
private _triggersEvents: EditorSelectOption[] = [ private _triggersEvents: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ {
@@ -3330,6 +3370,32 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
CONF_LIVE_CONTROLS_CALL_LOCK, CONF_LIVE_CONTROLS_CALL_LOCK,
this._defaults.live.controls.call.lock, this._defaults.live.controls.call.lock,
)} )}
${this._renderOptionSelector(
CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE,
this._callRingtoneTypes,
{
label: localize('config.live.controls.call.ringtone.type'),
},
)}
${this._renderStringInput(CONF_LIVE_CONTROLS_CALL_RINGTONE_URL, {
label: localize('config.live.controls.call.ringtone.url'),
})}
${this._renderNumberInput(
CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT,
{
min: 0,
default: this._defaults.live.controls.call.ringtone.repeat,
},
)}
${this._renderNumberInput(
CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS,
{
min: 0,
default:
this._defaults.live.controls.call
.unanswered_timeout_seconds,
},
)}
${this._renderNumberInput(CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, { ${this._renderNumberInput(CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, {
min: BUTTON_SIZE_MIN, min: BUTTON_SIZE_MIN,
})} })}
+16
View File
@@ -401,7 +401,21 @@
"lock": "Lock UI during an active call", "lock": "Lock UI during an active call",
"mute_audio": "Mute audio", "mute_audio": "Mute audio",
"mute_microphone": "Mute microphone", "mute_microphone": "Mute microphone",
"ringtone": {
"repeat": "Ringtone repeats per inbound call (0=indefinite)",
"type": "Ringtone for inbound calls",
"types": {
"arpeggio": "Arpeggio",
"chime": "Chime",
"custom": "Custom audio URL",
"melody": "Melody",
"none": "None",
"westminster": "Westminster"
},
"url": "Custom ringtone URL"
},
"start": "Start 2-way audio call", "start": "Start 2-way audio call",
"unanswered_timeout_seconds": "Seconds before ending unanswered inbound calls (0=never)",
"unmute_audio": "Unmute audio", "unmute_audio": "Unmute audio",
"unmute_microphone": "Unmute microphone" "unmute_microphone": "Unmute microphone"
}, },
@@ -646,6 +660,7 @@
"interaction_mode": "How to handle actions when the card has human interaction", "interaction_mode": "How to handle actions when the card has human interaction",
"trigger": "Trigger action", "trigger": "Trigger action",
"triggers": { "triggers": {
"call": "Start a 2-way audio call",
"default": "Change to or update default view", "default": "Change to or update default view",
"live": "Change to or update live view", "live": "Change to or update live view",
"media": "Change to the relevant media view for new media", "media": "Change to the relevant media view for new media",
@@ -653,6 +668,7 @@
}, },
"untrigger": "Untrigger action", "untrigger": "Untrigger action",
"untriggers": { "untriggers": {
"call": "End a 2-way audio call",
"default": "Change to default view/camera", "default": "Change to default view/camera",
"none": "No action" "none": "No action"
} }
+16 -1
View File
@@ -23,7 +23,12 @@ export class CarouselController {
private _startIndex: number; private _startIndex: number;
private _transitionEffect: TransitionEffect; private _transitionEffect: TransitionEffect;
private _loop: boolean; private _loop: boolean;
// Whether a drag releases into free-scroll momentum (`true`) or snaps to the
// nearest scroll snap (`false`).
private _dragFree: boolean; private _dragFree: boolean;
// Whether drag input is honored at all`.
private _draggable: boolean; private _draggable: boolean;
private _textDirection: TextDirection; private _textDirection: TextDirection;
private _wheelScrolling: boolean; private _wheelScrolling: boolean;
@@ -101,6 +106,13 @@ export class CarouselController {
this._carousel.scrollTo(index, this._transitionEffect === 'none'); this._carousel.scrollTo(index, this._transitionEffect === 'none');
} }
// Toggle drag handling live, without rebuilding Embla (e.g. to avoid visual
// "resetting" when a call is received). See the matching function call to
// watchDrag.
public setDragEnabled(enabled: boolean): void {
this._draggable = enabled;
}
private _refreshCarouselContents = (): void => { private _refreshCarouselContents = (): void => {
const slides = getChildrenFromElement(this._parent); const slides = getChildrenFromElement(this._parent);
const slidesChanged = !isEqual(this._carousel.slideNodes(), slides); const slidesChanged = !isEqual(this._carousel.slideNodes(), slides);
@@ -127,7 +139,10 @@ export class CarouselController {
// assignments, which the stock watcher does not handle). // assignments, which the stock watcher does not handle).
watchSlides: false, watchSlides: false,
watchResize: true, watchResize: true,
watchDrag: this._draggable,
// Function form so Embla re-evaluates per pointerdown -- lets us flip
// drag enablement at runtime without a `reInit` (see `setDragEnabled`).
watchDrag: () => this._draggable,
direction: this._textDirection, direction: this._textDirection,
}, },
@@ -14,7 +14,10 @@ it('should handle call_start action without a camera or stream', async () => {
await action.execute(api); await action.execute(api);
expect(api.getCallManager().start).toBeCalledWith(undefined, undefined); expect(api.getCallManager().start).toBeCalledWith({
cameraID: undefined,
streamID: undefined,
});
}); });
it('should handle call_start action with a camera and stream', async () => { it('should handle call_start action with a camera and stream', async () => {
@@ -31,8 +34,8 @@ it('should handle call_start action with a camera and stream', async () => {
await action.execute(api); await action.execute(api);
expect(api.getCallManager().start).toBeCalledWith( expect(api.getCallManager().start).toBeCalledWith({
'camera.front', cameraID: 'camera.front',
'camera.front_doorbell', streamID: 'camera.front_doorbell',
); });
}); });
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Ringtone } from '../../../src/card-controller/call/ringtone';
import { ArpeggioTone } from '../../../src/card-controller/call/tones/arpeggio';
import { ChimeTone } from '../../../src/card-controller/call/tones/chime';
import { CustomTone } from '../../../src/card-controller/call/tones/custom';
import { MelodyTone } from '../../../src/card-controller/call/tones/melody';
import { WestminsterTone } from '../../../src/card-controller/call/tones/westminster';
import { RingtoneConfig } from '../../../src/config/schema/live';
// Each tone constructor returns a fresh `mock<>()` per `new` call; the mock
// implementation persists across `vi.clearAllMocks()` (which only clears call
// records, not implementations) so tests don't need per-test re-installation.
vi.mock('../../../src/card-controller/call/tones/chime', () => ({
ChimeTone: vi.fn().mockImplementation(() => mock<ChimeTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/westminster', () => ({
WestminsterTone: vi.fn().mockImplementation(() => mock<WestminsterTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/arpeggio', () => ({
ArpeggioTone: vi.fn().mockImplementation(() => mock<ArpeggioTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/melody', () => ({
MelodyTone: vi.fn().mockImplementation(() => mock<MelodyTone>()),
}));
vi.mock('../../../src/card-controller/call/tones/custom', () => ({
CustomTone: vi.fn().mockImplementation(() => mock<CustomTone>()),
}));
// Returns the most recently constructed instance of a mocked class.
const lastInstance = <T>(ctor: { mock: { results: { value: T }[] } }): T => {
const result = ctor.mock.results.at(-1);
if (!result) {
throw new Error('No mocked instance has been constructed yet');
}
return result.value;
};
const chimeConfig: RingtoneConfig = { type: 'chime', repeat: 0 };
beforeEach(() => {
vi.clearAllMocks();
});
describe('factory dispatch', () => {
it('should construct a ChimeTone for type "chime"', () => {
new Ringtone(new Set()).start({ type: 'chime', repeat: 3 });
expect(ChimeTone).toBeCalledWith(3);
});
it('should construct a WestminsterTone for type "westminster"', () => {
new Ringtone(new Set()).start({ type: 'westminster', repeat: 2 });
expect(WestminsterTone).toBeCalledWith(2);
});
it('should construct an ArpeggioTone for type "arpeggio"', () => {
new Ringtone(new Set()).start({ type: 'arpeggio', repeat: 1 });
expect(ArpeggioTone).toBeCalledWith(1);
});
it('should construct a MelodyTone for type "melody"', () => {
new Ringtone(new Set()).start({ type: 'melody', repeat: 5 });
expect(MelodyTone).toBeCalledWith(5);
});
it('should construct a CustomTone for type "custom" with a URL', () => {
new Ringtone(new Set()).start({
type: 'custom',
url: 'http://localhost/ring.mp3',
repeat: 0,
});
expect(CustomTone).toBeCalledWith('http://localhost/ring.mp3', 0);
});
it('should construct no tone for type "custom" without a URL', () => {
const ringtone = new Ringtone(new Set());
ringtone.start({ type: 'custom', repeat: 0 });
expect(CustomTone).not.toBeCalled();
expect(ringtone.isPlaying()).toBe(false);
});
it('should construct no tone for type "none"', () => {
const ringtone = new Ringtone(new Set());
ringtone.start({ type: 'none', repeat: 0 });
expect(ChimeTone).not.toBeCalled();
expect(ringtone.isPlaying()).toBe(false);
});
});
describe('start', () => {
it('should start the tone and report playing', () => {
const ringtone = new Ringtone(new Set());
ringtone.start(chimeConfig);
expect(lastInstance(vi.mocked(ChimeTone)).start).toBeCalled();
expect(ringtone.isPlaying()).toBe(true);
});
it('should no-op when already playing', () => {
const ringtone = new Ringtone(new Set());
ringtone.start(chimeConfig);
ringtone.start(chimeConfig);
expect(ChimeTone).toBeCalledTimes(1);
expect(lastInstance(vi.mocked(ChimeTone)).start).toBeCalledTimes(1);
});
it('should claim the lock when a tone starts', () => {
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start(chimeConfig);
expect(lock.has(ringtone)).toBe(true);
});
it('should not claim the lock when no tone is created', () => {
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start({ type: 'none', repeat: 0 });
expect(lock.size).toBe(0);
});
});
describe('lock', () => {
it('should refuse to start when another ringtone holds the lock', () => {
const lock = new Set<Ringtone>();
const first = new Ringtone(lock);
const second = new Ringtone(lock);
first.start(chimeConfig);
vi.mocked(ChimeTone).mockClear();
second.start(chimeConfig);
expect(ChimeTone).not.toBeCalled();
expect(second.isPlaying()).toBe(false);
expect(first.isPlaying()).toBe(true);
});
it('should release the lock on stop so a peer can start', () => {
const lock = new Set<Ringtone>();
const first = new Ringtone(lock);
const second = new Ringtone(lock);
first.start(chimeConfig);
const firstTone = lastInstance(vi.mocked(ChimeTone));
first.stop();
expect(firstTone.stop).toBeCalled();
second.start(chimeConfig);
expect(lastInstance(vi.mocked(ChimeTone)).start).toBeCalled();
expect(second.isPlaying()).toBe(true);
});
it('should sweep stale holders whose tone never released the lock', () => {
const lock = new Set<Ringtone>();
const stale = new Ringtone(lock);
// Simulate a stale entry: a holder that says it's no longer playing but
// is still in the lock set (e.g. controller GC'd without disconnect).
lock.add(stale);
expect(stale.isPlaying()).toBe(false);
const fresh = new Ringtone(lock);
fresh.start(chimeConfig);
expect(lock.has(stale)).toBe(false);
expect(fresh.isPlaying()).toBe(true);
});
});
describe('natural finish', () => {
it('should release the lock when the tone fires its finished handler', () => {
let finishedHandler: (() => void) | undefined;
vi.mocked(ChimeTone).mockImplementationOnce(() => {
const tone = mock<ChimeTone>();
vi.mocked(tone.start).mockImplementation((handler) => {
finishedHandler = handler;
});
return tone;
});
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start(chimeConfig);
expect(lock.has(ringtone)).toBe(true);
finishedHandler?.();
expect(lock.has(ringtone)).toBe(false);
expect(ringtone.isPlaying()).toBe(false);
});
});
describe('stop', () => {
it('should release the lock and stop the tone', () => {
const lock = new Set<Ringtone>();
const ringtone = new Ringtone(lock);
ringtone.start(chimeConfig);
ringtone.stop();
expect(lastInstance(vi.mocked(ChimeTone)).stop).toBeCalled();
expect(lock.has(ringtone)).toBe(false);
expect(ringtone.isPlaying()).toBe(false);
});
it('should be safe when called without a prior start', () => {
const ringtone = new Ringtone(new Set());
expect(() => ringtone.stop()).not.toThrow();
});
});
describe('default lock', () => {
it('should default to the module-level lock when no lock is provided', () => {
// Two ringtones constructed with no args share the module-level lock, so
// the second must refuse to play while the first holds it.
const first = new Ringtone();
const second = new Ringtone();
first.start(chimeConfig);
vi.mocked(ChimeTone).mockClear();
second.start(chimeConfig);
expect(ChimeTone).not.toBeCalled();
first.stop();
});
});
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { ArpeggioTone } from '../../../../src/card-controller/call/tones/arpeggio';
import { useAudioMocks } from './test-utils';
// Each strike emits a 3-layer bell stack in order: sparkle (octave above
// fundamental), fundamental, hum (octave below). Indices into
// `audio.oscillators` step through the three descending plucks.
describe('ArpeggioTone', () => {
const audio = useAudioMocks();
it('should play G5-E5-C5 descending plucks 0.25s apart', () => {
new ArpeggioTone(0).start();
// 3 strikes × 3 layers = 9 oscillators.
expect(audio.oscillators).toHaveLength(9);
// G5 (783.99) at t=0.
expect(audio.oscillators[1].frequency.value).toBe(783.99);
expect(audio.oscillators[1].start).toBeCalledWith(0);
// E5 (659.25) at t=0.25.
expect(audio.oscillators[4].frequency.value).toBe(659.25);
expect(audio.oscillators[4].start).toBeCalledWith(0.25);
// C5 (523.25) at t=0.5.
expect(audio.oscillators[7].frequency.value).toBe(523.25);
expect(audio.oscillators[7].start).toBeCalledWith(0.5);
});
it('should use the lighter PLUCK envelope for every strike', () => {
new ArpeggioTone(0).start();
// Sparkle / fundamental / hum peaks for every strike.
for (let strike = 0; strike < 3; strike++) {
const i = strike * 3;
expect(audio.gainParams[i].linearRampToValueAtTime).toBeCalledWith(
0.05,
expect.any(Number),
);
expect(audio.gainParams[i + 1].linearRampToValueAtTime).toBeCalledWith(
0.13,
expect.any(Number),
);
expect(audio.gainParams[i + 2].linearRampToValueAtTime).toBeCalledWith(
0.04,
expect.any(Number),
);
}
});
});
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChimeTone } from '../../../../src/card-controller/call/tones/chime';
import { useAudioMocks } from './test-utils';
// `GeneratedTone` is abstract; its shared machinery (AudioContext lifecycle,
// repeat-counter scheduling, stop-suppresses-finishedHandler) is exercised
// through a real concrete subclass. `ChimeTone` is the chosen vehicle: it
// produces a deterministic 6-oscillator iteration (2 strikes × 3 bell-stack
// layers) and loops every 5 seconds, giving stable counts to assert on.
const ITERATION_OSCILLATORS = 6;
const ITERATION_INTERVAL_MS = 5_000;
const audio = useAudioMocks();
// @vitest-environment jsdom
describe('start', () => {
it('should construct an AudioContext and play one iteration', () => {
new ChimeTone(0).start();
expect(audio.audioContextCtor).toBeCalledTimes(1);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
});
it('should no-op when called twice without stop', () => {
const tone = new ChimeTone(0);
tone.start();
tone.start();
expect(audio.audioContextCtor).toBeCalledTimes(1);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
});
it('should fire finishedHandler when AudioContext construction throws', () => {
audio.audioContextCtor.mockImplementation(() => {
throw new Error('unsupported');
});
const onFinished = vi.fn();
new ChimeTone(0).start(onFinished);
expect(onFinished).toBeCalled();
expect(audio.oscillators).toHaveLength(0);
});
});
describe('stop', () => {
it('should close the AudioContext', () => {
const tone = new ChimeTone(0);
tone.start();
tone.stop();
expect(audio.audioContext.close).toBeCalled();
});
it('should not fire finishedHandler on external stop', () => {
const tone = new ChimeTone(0);
const onFinished = vi.fn();
tone.start(onFinished);
tone.stop();
expect(onFinished).not.toBeCalled();
});
it('should swallow AudioContext.close rejections silently', () => {
vi.mocked(audio.audioContext.close).mockRejectedValue(new Error('already-closed'));
const tone = new ChimeTone(0);
tone.start();
expect(() => tone.stop()).not.toThrow();
});
});
describe('repeat counter', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should loop indefinitely when repeat is 0', () => {
new ChimeTone(0).start();
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
for (let i = 2; i <= 5; i++) {
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * i);
}
expect(audio.audioContext.close).not.toBeCalled();
});
it('should play exactly `repeat` iterations and then finish', () => {
const onFinished = vi.fn();
new ChimeTone(3).start(onFinished);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS);
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * 2);
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * 3);
// After the third iteration the next timer waits one interval for the
// decay tail, then fires the finished handler and stops.
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
expect(audio.oscillators).toHaveLength(ITERATION_OSCILLATORS * 3);
expect(onFinished).toBeCalledTimes(1);
expect(audio.audioContext.close).toBeCalled();
});
it('should not fire finishedHandler when stopped mid-sequence', () => {
const tone = new ChimeTone(5);
const onFinished = vi.fn();
tone.start(onFinished);
vi.advanceTimersByTime(ITERATION_INTERVAL_MS);
tone.stop();
// Even if any stale scheduled work fires, finishedHandler stays silent.
vi.advanceTimersByTime(ITERATION_INTERVAL_MS * 10);
expect(onFinished).not.toBeCalled();
});
});
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChimeTone } from '../../../../src/card-controller/call/tones/chime';
import { useAudioMocks } from './test-utils';
const audio = useAudioMocks();
// Each strike emits a 3-layer bell stack in the order: sparkle (one octave
// above the fundamental), fundamental, hum (one octave below). The frequencies
// here are the per-layer values derived from each strike's fundamental.
describe('ChimeTone', () => {
it('should play DING (Eb5) then DOOOOONG (B4) 0.5s later', () => {
new ChimeTone(0).start();
// 2 strikes × 3 layers = 6 oscillators.
expect(audio.oscillators).toHaveLength(6);
// DING -- Eb5 (622.25Hz) at t=0.
expect(audio.oscillators[0].frequency.value).toBe(622.25 * 2);
expect(audio.oscillators[1].frequency.value).toBe(622.25);
expect(audio.oscillators[2].frequency.value).toBe(622.25 / 2);
expect(audio.oscillators[0].start).toBeCalledWith(0);
expect(audio.oscillators[1].start).toBeCalledWith(0);
expect(audio.oscillators[2].start).toBeCalledWith(0);
// DOOOOONG -- B4 (493.88Hz) at t=0.5.
expect(audio.oscillators[3].frequency.value).toBe(493.88 * 2);
expect(audio.oscillators[4].frequency.value).toBe(493.88);
expect(audio.oscillators[5].frequency.value).toBe(493.88 / 2);
expect(audio.oscillators[3].start).toBeCalledWith(0.5);
expect(audio.oscillators[4].start).toBeCalledWith(0.5);
expect(audio.oscillators[5].start).toBeCalledWith(0.5);
});
it('should give DING a brighter, shorter bell envelope', () => {
new ChimeTone(0).start();
// Sparkle / fundamental / hum peaks for DING.
expect(audio.gainParams[0].linearRampToValueAtTime).toBeCalledWith(0.1, 0.005);
expect(audio.gainParams[1].linearRampToValueAtTime).toBeCalledWith(0.22, 0.005);
expect(audio.gainParams[2].linearRampToValueAtTime).toBeCalledWith(0.08, 0.005);
// Decay constants (sparkle fades fastest, hum lingers).
expect(audio.gainParams[0].setTargetAtTime).toBeCalledWith(0, 0.005, 0.3);
expect(audio.gainParams[1].setTargetAtTime).toBeCalledWith(0, 0.005, 0.8);
expect(audio.gainParams[2].setTargetAtTime).toBeCalledWith(0, 0.005, 1.2);
});
it('should give DOOOOONG a fuller, longer bell envelope', () => {
new ChimeTone(0).start();
expect(audio.gainParams[3].linearRampToValueAtTime).toBeCalledWith(0.11, 0.505);
expect(audio.gainParams[4].linearRampToValueAtTime).toBeCalledWith(0.28, 0.505);
expect(audio.gainParams[5].linearRampToValueAtTime).toBeCalledWith(0.1, 0.505);
expect(audio.gainParams[3].setTargetAtTime).toBeCalledWith(0, 0.505, 0.5);
expect(audio.gainParams[4].setTargetAtTime).toBeCalledWith(0, 0.505, 1.3);
expect(audio.gainParams[5].setTargetAtTime).toBeCalledWith(0, 0.505, 1.8);
});
describe('with fake timers', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should schedule the next iteration 5 seconds after a strike pair', () => {
new ChimeTone(0).start();
expect(audio.oscillators).toHaveLength(6);
vi.advanceTimersByTime(5_000);
// A second iteration ran, producing another 6 oscillators.
expect(audio.oscillators).toHaveLength(12);
});
});
});
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { CustomTone } from '../../../../src/card-controller/call/tones/custom';
interface AudioMocks {
// Each call to `new Audio(...)` is delegated to a real jsdom Audio element
// and pushed here in construction order, so tests can dispatch real events
// and read real properties (`loop`, `currentTime`, etc.) on the instances.
instances: HTMLAudioElement[];
ctor: Mock<[string?], HTMLAudioElement>;
}
// Uses real jsdom HTMLAudioElement instances and only stubs the parts jsdom
// can't fulfil (`play()` / `pause()` — no audio backend). Tests then exercise
// observable behaviour: registered listeners fire via `dispatchEvent`,
// property writes round-trip on the element, etc.
//
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
// helper register Vitest hooks at the file level — Vitest picks them up just
// as if they had been written at the top of the file — so every test in the
// file gets fresh mocks installed/torn down automatically.
const useAudioElementMocks = (): AudioMocks => {
const handle = { instances: [] as HTMLAudioElement[] } as AudioMocks;
beforeEach(() => {
handle.instances = [];
// jsdom's HTMLMediaElement.play() rejects by default (no media backend);
// resolve it so the source's `.catch(...)` natural-finish path isn't
// triggered by every play call. Tests can override per-case.
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue();
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
const RealAudio = window.Audio;
handle.ctor = vi.fn((url?: string) => {
const audio = new RealAudio(url);
handle.instances.push(audio);
return audio;
});
vi.stubGlobal('Audio', handle.ctor);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
return handle;
};
const audio = useAudioElementMocks();
// @vitest-environment jsdom
describe('start', () => {
it('should construct an Audio element with the configured URL', () => {
new CustomTone('http://example/ring.mp3', 0).start();
expect(audio.ctor).toBeCalledWith('http://example/ring.mp3');
});
it('should loop indefinitely when repeat is 0', () => {
new CustomTone('http://example/ring.mp3', 0).start();
expect(audio.instances[0].loop).toBe(true);
expect(audio.instances[0].play).toBeCalled();
});
it('should re-play (not loop natively) when repeat is finite', () => {
new CustomTone('http://example/ring.mp3', 2).start();
expect(audio.instances[0].loop).not.toBe(true);
// Observable proof the 'ended' listener was registered: dispatching the
// event triggers a second play().
audio.instances[0].dispatchEvent(new Event('ended'));
expect(audio.instances[0].play).toBeCalledTimes(2);
});
it('should reset currentTime before play', () => {
new CustomTone('http://example/ring.mp3', 0).start();
expect(audio.instances[0].currentTime).toBe(0);
expect(audio.instances[0].play).toBeCalled();
});
it('should no-op when called twice without stop', () => {
const tone = new CustomTone('http://example/ring.mp3', 0);
tone.start();
tone.start();
expect(audio.ctor).toBeCalledTimes(1);
});
it('should fire finishedHandler when Audio construction throws', () => {
audio.ctor.mockImplementation(() => {
throw new Error('unsupported');
});
const onFinished = vi.fn();
new CustomTone('http://example/ring.mp3', 0).start(onFinished);
expect(onFinished).toBeCalled();
});
it('should fire finishedHandler when play() rejects (e.g. autoplay block)', async () => {
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValue(
new Error('autoplay-blocked'),
);
const onFinished = vi.fn();
new CustomTone('http://example/ring.mp3', 0).start(onFinished);
// Let the rejected promise settle.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFinished).toBeCalled();
});
});
describe('repeat counter', () => {
it('should fire finishedHandler after the configured number of iterations', () => {
const onFinished = vi.fn();
new CustomTone('http://example/ring.mp3', 3).start(onFinished);
// Iteration 1 already started by `start()`. Two more 'ended' events
// should re-play, and the third 'ended' should finish.
expect(audio.instances[0].play).toBeCalledTimes(1);
audio.instances[0].dispatchEvent(new Event('ended'));
expect(audio.instances[0].play).toBeCalledTimes(2);
audio.instances[0].dispatchEvent(new Event('ended'));
expect(audio.instances[0].play).toBeCalledTimes(3);
expect(onFinished).not.toBeCalled();
audio.instances[0].dispatchEvent(new Event('ended'));
expect(onFinished).toBeCalledTimes(1);
});
it('should ignore ended events after stop', () => {
const onFinished = vi.fn();
const tone = new CustomTone('http://example/ring.mp3', 3);
tone.start(onFinished);
const element = audio.instances[0];
tone.stop();
// stop() should have removed the 'ended' listener, so dispatching is a
// no-op as far as the source is concerned.
element.dispatchEvent(new Event('ended'));
expect(onFinished).not.toBeCalled();
});
});
describe('stop', () => {
it('should pause and detach the audio element', () => {
const tone = new CustomTone('http://example/ring.mp3', 2);
tone.start();
const element = audio.instances[0];
tone.stop();
expect(element.pause).toBeCalled();
// Confirm the 'ended' listener is gone: dispatching it must not re-play.
vi.mocked(HTMLMediaElement.prototype.play).mockClear();
element.dispatchEvent(new Event('ended'));
expect(element.play).not.toBeCalled();
});
it('should not fire finishedHandler on external stop', () => {
const onFinished = vi.fn();
const tone = new CustomTone('http://example/ring.mp3', 0);
tone.start(onFinished);
tone.stop();
expect(onFinished).not.toBeCalled();
});
it('should be safe to call before start', () => {
expect(() => new CustomTone('http://example/ring.mp3', 0).stop()).not.toThrow();
});
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { MelodyTone } from '../../../../src/card-controller/call/tones/melody';
import { useAudioMocks } from './test-utils';
const audio = useAudioMocks();
// MelodyTone synthesizes each chord as: 1 sparkle (an octave above the
// highest chord note) + 3 chord notes + 1 hum (an octave below the lowest).
// Three chords × 5 notes = 15 oscillators per iteration.
describe('MelodyTone', () => {
it('should play a I-V-I cadence in C major over 3 seconds', () => {
new MelodyTone(0).start();
expect(audio.oscillators).toHaveLength(15);
// --- I chord (C major) at t=0: sparkle G6, C5 + E5 + G5, hum C4. ---
expect(audio.oscillators[0].frequency.value).toBe(1567.98);
expect(audio.oscillators[1].frequency.value).toBe(523.25);
expect(audio.oscillators[2].frequency.value).toBe(659.25);
expect(audio.oscillators[3].frequency.value).toBe(783.99);
expect(audio.oscillators[4].frequency.value).toBe(261.63);
expect(audio.oscillators[0].start).toBeCalledWith(0);
expect(audio.oscillators[4].start).toBeCalledWith(0);
// --- V chord (G major) at t=1: sparkle D6, G4 + B4 + D5, hum G3. ---
expect(audio.oscillators[5].frequency.value).toBe(1174.66);
expect(audio.oscillators[6].frequency.value).toBe(392.0);
expect(audio.oscillators[7].frequency.value).toBe(493.88);
expect(audio.oscillators[8].frequency.value).toBe(587.33);
expect(audio.oscillators[9].frequency.value).toBe(196.0);
expect(audio.oscillators[5].start).toBeCalledWith(1);
expect(audio.oscillators[9].start).toBeCalledWith(1);
// --- I chord (resolution, an octave higher) at t=2. ---
expect(audio.oscillators[10].frequency.value).toBe(2093.0);
expect(audio.oscillators[11].frequency.value).toBe(659.25);
expect(audio.oscillators[12].frequency.value).toBe(783.99);
expect(audio.oscillators[13].frequency.value).toBe(1046.5);
expect(audio.oscillators[14].frequency.value).toBe(329.63);
expect(audio.oscillators[10].start).toBeCalledWith(2);
expect(audio.oscillators[14].start).toBeCalledWith(2);
});
it('should give the resolving chord a longer tail than the I and V chords', () => {
new MelodyTone(0).start();
// I and V chords use default fundDecay=0.6, humDecay=1.1.
expect(audio.gainParams[1].setTargetAtTime).toBeCalledWith(0, 0.005, 0.6);
expect(audio.gainParams[4].setTargetAtTime).toBeCalledWith(0, 0.005, 1.1);
expect(audio.gainParams[6].setTargetAtTime).toBeCalledWith(0, 1.005, 0.6);
expect(audio.gainParams[9].setTargetAtTime).toBeCalledWith(0, 1.005, 1.1);
// Final I chord overrides to fundDecay=0.9, humDecay=1.4.
expect(audio.gainParams[11].setTargetAtTime).toBeCalledWith(0, 2.005, 0.9);
expect(audio.gainParams[14].setTargetAtTime).toBeCalledWith(0, 2.005, 1.4);
});
});
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, Mock, vi } from 'vitest';
import { mock, MockProxy } from 'vitest-mock-extended';
// Type-safe Web Audio API mocks for tone tests. The real `GeneratedTone`
// constructs an AudioContext directly; here we stub the global `AudioContext`
// to return a deep-mocked instance whose `createOscillator()` / `createGain()`
// factories return a *fresh* mock per call (not a shared one). This lets tests
// assert against individual notes — e.g. `audio.oscillators[2].frequency.value`
// — instead of having every call write over the same observable state.
interface AudioMocks {
audioContext: MockProxy<AudioContext>;
audioContextCtor: Mock<[], MockProxy<AudioContext>>;
// Filled in the order `createOscillator()` / `createGain()` were called.
oscillators: MockProxy<OscillatorNode>[];
gains: MockProxy<GainNode>[];
// gainParams[i] is the AudioParam exposed by `gains[i].gain` — kept as a
// parallel array because `mock<GainNode>()` doesn't auto-populate the
// AudioParam interface as a callable deep mock (we wire it up by hand).
gainParams: MockProxy<AudioParam>[];
}
// Installs `AudioContext` mock on the global scope and resets it between tests;
// restores real globals afterwards. Returns the live `audio` handle so tests
// can read its fields after each `beforeEach` runs.
//
// Called once at module load. The `beforeEach`/`afterEach` calls inside this
// helper register Vitest hooks at the file level — Vitest picks them up just as
// if they had been written at the top of the file — so every test in the file
// gets fresh mocks installed/torn down automatically.
export const useAudioMocks = (): AudioMocks => {
const audio = {} as AudioMocks;
beforeEach(() => {
audio.audioContext = mock<AudioContext>();
audio.oscillators = [];
audio.gains = [];
audio.gainParams = [];
vi.mocked(audio.audioContext.createOscillator).mockImplementation(() => {
const oscillator = mock<OscillatorNode>();
// `frequency` is a real AudioParam at runtime; the source assigns
// `oscillator.frequency.value = freq` which must round-trip on read.
Object.defineProperty(oscillator, 'frequency', {
value: { value: 0 },
configurable: true,
});
audio.oscillators.push(oscillator);
return oscillator;
});
vi.mocked(audio.audioContext.createGain).mockImplementation(() => {
const gain = mock<GainNode>();
const gainParam = mock<AudioParam>();
Object.defineProperty(gain, 'gain', {
value: gainParam,
configurable: true,
});
audio.gains.push(gain);
audio.gainParams.push(gainParam);
return gain;
});
// The source chains `.catch(...)` on the close() Promise.
vi.mocked(audio.audioContext.close).mockResolvedValue();
// The base class reads `_currentTime` from this — left as a deep-mock spy
// by default it'd return a function, so anchor it at 0 for predictable
// scheduling assertions.
Object.defineProperty(audio.audioContext, 'currentTime', {
value: 0,
configurable: true,
});
audio.audioContextCtor = vi.fn(() => audio.audioContext);
vi.stubGlobal('AudioContext', audio.audioContextCtor);
});
afterEach(() => {
vi.unstubAllGlobals();
});
return audio;
};
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { WestminsterTone } from '../../../../src/card-controller/call/tones/westminster';
import { useAudioMocks } from './test-utils';
const audio = useAudioMocks();
// Each strike emits a 3-layer bell stack in order: sparkle (octave above
// fundamental), fundamental, hum (octave below). Indices into
// `audio.oscillators` step through the four strikes of the phrase.
describe('WestminsterTone', () => {
it('should play the E5-D5-C5-G4 phrase 0.55s apart', () => {
new WestminsterTone(0).start();
// 4 strikes × 3 layers = 12 oscillators.
expect(audio.oscillators).toHaveLength(12);
// E5 (659.25) at t=0.
expect(audio.oscillators[1].frequency.value).toBe(659.25);
expect(audio.oscillators[1].start).toBeCalledWith(0);
// D5 (587.33) at t=0.55.
expect(audio.oscillators[4].frequency.value).toBe(587.33);
expect(audio.oscillators[4].start).toBeCalledWith(0.55);
// C5 (523.25) at t=1.1.
expect(audio.oscillators[7].frequency.value).toBe(523.25);
expect(audio.oscillators[7].start).toBeCalledWith(1.1);
// G4 (392.0) at t=1.65 -- the resolution.
expect(audio.oscillators[10].frequency.value).toBe(392.0);
expect(audio.oscillators[10].start).toBeCalledWith(1.65);
});
it('should give the resolving G4 a longer bell tail than the other strikes', () => {
new WestminsterTone(0).start();
// First three strikes use default decay (fundDecay=0.6, humDecay=1.0).
expect(audio.gainParams[1].setTargetAtTime).toBeCalledWith(0, 0.005, 0.6);
expect(audio.gainParams[2].setTargetAtTime).toBeCalledWith(0, 0.005, 1.0);
// G4 (final strike) overrides to fundDecay=0.9, humDecay=1.4.
expect(audio.gainParams[10].setTargetAtTime).toBeCalledWith(0, 1.65 + 0.005, 0.9);
expect(audio.gainParams[11].setTargetAtTime).toBeCalledWith(0, 1.65 + 0.005, 1.4);
});
});
@@ -143,6 +143,7 @@ describe('CardElementManager', () => {
expect(api.getExpandManager().initialize).toBeCalled(); expect(api.getExpandManager().initialize).toBeCalled();
expect(api.getMediaLoadedInfoManager().initialize).toBeCalled(); expect(api.getMediaLoadedInfoManager().initialize).toBeCalled();
expect(api.getMicrophoneManager().initialize).toBeCalled(); expect(api.getMicrophoneManager().initialize).toBeCalled();
expect(api.getCallManager().initialize).toBeCalled();
}); });
it('should disconnect', () => { it('should disconnect', () => {
@@ -209,6 +210,7 @@ describe('CardElementManager', () => {
expect(api.getFullscreenManager().disconnect).toBeCalled(); expect(api.getFullscreenManager().disconnect).toBeCalled();
expect(api.getKeyboardStateManager().uninitialize).toBeCalled(); expect(api.getKeyboardStateManager().uninitialize).toBeCalled();
expect(api.getActionsManager().uninitialize).toBeCalled(); expect(api.getActionsManager().uninitialize).toBeCalled();
expect(api.getCallManager().uninitialize).toBeCalled();
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras'); expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
}); });
@@ -291,6 +291,55 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
}); });
it('should handle trigger action set to call', async () => {
const api = createTriggerAPI({
config: {
actions: { trigger: 'call' },
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
// `start()` is called with the triggered camera and the inbound flag --
// view navigation is delegated to CallManager itself.
expect(api.getCallManager().start).toBeCalledWith({
cameraID: 'camera_1',
inbound: true,
});
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('should start a call on a high-fidelity event with no media', async () => {
// The high-fidelity-no-media skip-guard intentionally lets `call`
// through -- calls don't depend on a new media item being available.
const api = createTriggerAPI({
config: {
actions: { trigger: 'call' },
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
fidelity: 'high',
});
expect(api.getCallManager().start).toBeCalledWith({
cameraID: 'camera_1',
inbound: true,
});
});
}); });
describe('untrigger actions', () => { describe('untrigger actions', () => {
@@ -361,6 +410,38 @@ describe('TriggersManager', () => {
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled(); expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
}); });
it('should handle untrigger action set to call', async () => {
const api = createTriggerAPI({
config: {
actions: { trigger: 'none', untrigger: 'call' },
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'new',
});
await manager.handleCameraEvent({
cameraID: 'camera_1',
id: 'event-1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getCallManager().endIf).toBeCalledWith({
cameraID: 'camera_1',
inbound: true,
answered: false,
});
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
});
it('should handle untrigger call with no state', async () => { it('should handle untrigger call with no state', async () => {
const api = createTriggerAPI(); const api = createTriggerAPI();
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
+2
View File
@@ -94,6 +94,8 @@ describe('config defaults', () => {
call: { call: {
button_size: 40, button_size: 40,
lock: true, lock: true,
ringtone: { type: 'chime', repeat: 0 },
unanswered_timeout_seconds: 60,
}, },
next_previous: { next_previous: {
auto_hide: ['call', 'casting'], auto_hide: ['call', 'casting'],
+35 -1
View File
@@ -195,13 +195,47 @@ describe('CarouselController', () => {
containScroll: 'trimSnaps', containScroll: 'trimSnaps',
watchSlides: false, watchSlides: false,
watchResize: true, watchResize: true,
watchDrag: false, watchDrag: expect.any(Function),
direction: 'rtl', direction: 'rtl',
}, },
[], [],
); );
}); });
it('should pass a watchDrag predicate reflecting the current drag state', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
const carousel = new CarouselController(root, parent, { dragEnabled: true });
const emblaOptions = vi.mocked(EmblaCarousel).mock.calls[0][1] as {
watchDrag: () => boolean;
};
expect(emblaOptions.watchDrag()).toBe(true);
carousel.setDragEnabled(false);
expect(emblaOptions.watchDrag()).toBe(false);
carousel.setDragEnabled(true);
expect(emblaOptions.watchDrag()).toBe(true);
});
it('should toggle drag without rebuilding the carousel', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent, {
dragEnabled: true,
});
const emblaApi = getEmblaApi();
expect(emblaApi).toBeTruthy();
carousel.setDragEnabled(false);
expect(emblaApi?.reInit).not.toBeCalled();
expect(emblaApi?.destroy).not.toBeCalled();
});
it('should include wheel plugin when slides > 1', () => { it('should include wheel plugin when slides > 1', () => {
const children = createTestSlideNodes(); const children = createTestSlideNodes();
const root = createRoot(); const root = createRoot();