feat: Add support for inbound "calls" from triggers (#2500)
This commit is contained in:
committed by
dermotduffy
parent
f4c686c298
commit
15e335a647
@@ -55,10 +55,36 @@ live:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| Option | Default | Description |
|
||||
| ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `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`
|
||||
|
||||
@@ -234,6 +260,10 @@ live:
|
||||
call:
|
||||
button_size: 40
|
||||
lock: true
|
||||
ringtone:
|
||||
type: chime
|
||||
repeat: 0
|
||||
unanswered_timeout_seconds: 60
|
||||
next_previous:
|
||||
auto_hide:
|
||||
- call
|
||||
|
||||
@@ -162,11 +162,11 @@ human interaction with the card; this behavior can be configured via the
|
||||
|
||||
### Trigger action configuration
|
||||
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| 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. |
|
||||
| `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 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
|
||||
|
||||
|
||||
@@ -359,6 +359,46 @@ elements:
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
`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> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { createNotificationFromText } from '../../components-lib/notification/factory';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { getStreamCameraID } from '../../view/substream';
|
||||
import { View } from '../../view/view';
|
||||
import { CardCallAPI } from '../types';
|
||||
import { SubstreamViewModifier } from '../view/modifiers/substream';
|
||||
import { Ringtone } from './ringtone';
|
||||
import { CallSession } from './types';
|
||||
|
||||
export class CallManager {
|
||||
private _api: CardCallAPI;
|
||||
private _call: CallSession | null = null;
|
||||
private _ringtone = new Ringtone();
|
||||
private _unansweredTimer = new Timer();
|
||||
|
||||
constructor(api: CardCallAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
// A call runs on the live view of a specific camera. Observe the
|
||||
// condition state so the call can be ended if the view, camera or engaged
|
||||
// substream moves off what the call started on.
|
||||
public initialize(): void {
|
||||
// A call runs on the live view of a specific camera. The listener watches
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -36,12 +43,19 @@ export class CallManager {
|
||||
// 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 parentID = cameraID ?? view?.camera;
|
||||
const parentID = options?.cameraID ?? view?.camera;
|
||||
if (!view || !parentID) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -51,15 +65,15 @@ export class CallManager {
|
||||
.getCameraIDsWithCapability('live')
|
||||
.has(parentID)
|
||||
) {
|
||||
this._notifyError('error.call_invalid_target');
|
||||
return;
|
||||
this._notifyError('error.call_invalid_target', inbound);
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetID = streamID
|
||||
? this._validateStream(parentID, streamID)
|
||||
: this._pickDefaultTarget(view, parentID);
|
||||
const targetID = options?.streamID
|
||||
? this._validateStream(parentID, options.streamID, inbound)
|
||||
: this._pickDefaultTarget(view, parentID, inbound);
|
||||
if (!targetID) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// `callCameraID` is the substream carrying the call audio -- absent when
|
||||
@@ -72,17 +86,17 @@ export class CallManager {
|
||||
existingCall.cameraID === parentID &&
|
||||
existingCall.callCameraID === callCameraID
|
||||
) {
|
||||
// This exact call (same parent camera and stream) is already running; a
|
||||
// repeat request must not disrupt it.
|
||||
return;
|
||||
// This exact call (same parent camera and stream) is already running --
|
||||
// the caller has what they asked for.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._microphonePreflight()) {
|
||||
return;
|
||||
if (!this._microphonePreflight(inbound)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await this._connectMicrophone())) {
|
||||
return;
|
||||
if (!(await this._connectMicrophone(inbound))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Any other call in progress is superseded. Ended here -- after the
|
||||
// preflight passes -- so a failed preflight leaves the existing call
|
||||
// intact.
|
||||
// An inbound call must not yank the user away from a call they care about.
|
||||
// Skip the new start request if the existing call is either manual
|
||||
// (user-initiated) or already answered (user engaged). Newer inbound rings
|
||||
// still replace older unanswered ones.
|
||||
if (existingCall) {
|
||||
if (inbound && (existingCall.answered || !existingCall.inbound)) {
|
||||
return 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 = {
|
||||
cameraID: parentID,
|
||||
...(callCameraID && { callCameraID }),
|
||||
previousView,
|
||||
inbound,
|
||||
answered,
|
||||
};
|
||||
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
@@ -115,12 +138,79 @@ export class CallManager {
|
||||
force: 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;
|
||||
}
|
||||
|
||||
// Ring only if still unanswered.
|
||||
const callConfig = this._api.getConfigManager().getConfig()?.live.controls.call;
|
||||
const ringtoneConfig = callConfig?.ringtone;
|
||||
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 view that was showing before
|
||||
// `call_start` -- the user-facing `call_end`.
|
||||
public end(): void {
|
||||
this._end(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
|
||||
@@ -128,13 +218,18 @@ export class CallManager {
|
||||
// is `false` for auto-ends (navigating away, camera/substream change), where
|
||||
// the user has already chosen a destination and the pre-call view is
|
||||
// deliberately not reinstated; only the manager's own auto-end paths pass it.
|
||||
private _end(restoreView: boolean): void {
|
||||
// Returns true iff a call was actually ended.
|
||||
private _end(restoreView: boolean): boolean {
|
||||
if (!this._call) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const call = this._call;
|
||||
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
|
||||
// the resulting condition-state change must not see this (now-ending) call
|
||||
// and recurse.
|
||||
@@ -171,17 +266,43 @@ export class CallManager {
|
||||
});
|
||||
}
|
||||
this._api.getConditionStateManager().setState({ call: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
// End the call once it can no longer be conducted from where it started: the
|
||||
// view leaves `live` (the call overlay exists only there, so the call would
|
||||
// otherwise be stranded with no controls), the selected camera changes, or
|
||||
// the engaged substream moves off the call's audio source. Covers navigation
|
||||
// and `live_substream_*` actions taken while `live.controls.call.lock` is
|
||||
// disabled, as well as any forced view change.
|
||||
// Watches condition state for two transitions during an active call:
|
||||
//
|
||||
// 1. End the call once it can no longer be conducted from where it started
|
||||
// (e.g. view change). Only react to changes in view/camera/substream
|
||||
// themselves -- not to unrelated state updates (e.g. `mediaLoadedInfo`)
|
||||
// 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 => {
|
||||
if (!this._call) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.camera !== this._call.cameraID ||
|
||||
stateChange.new.substreamID !== this._call.callCameraID)
|
||||
@@ -194,7 +315,11 @@ export class CallManager {
|
||||
// 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(
|
||||
createNotificationFromText(localize(messageKey), {
|
||||
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
|
||||
// surfaced).
|
||||
private _microphonePreflight(): boolean {
|
||||
// Returns `true` to proceed, `false` to abort (with a notification surfaced
|
||||
// unless `inbound` is set).
|
||||
private _microphonePreflight(inbound: boolean): boolean {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
|
||||
if (!microphoneManager.isSupported()) {
|
||||
this._notifyError('error.call_microphone_unsupported');
|
||||
this._notifyError('error.call_microphone_unsupported', inbound);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (microphoneManager.isForbidden()) {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
this._notifyError('error.call_microphone_forbidden', inbound);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _connectMicrophone(): Promise<boolean> {
|
||||
private async _connectMicrophone(inbound: boolean): Promise<boolean> {
|
||||
const microphoneManager = this._api.getMicrophoneManager();
|
||||
if (microphoneManager.isConnected()) {
|
||||
return true;
|
||||
@@ -229,7 +354,7 @@ export class CallManager {
|
||||
await microphoneManager.connect();
|
||||
return true;
|
||||
} catch {
|
||||
this._notifyError('error.call_microphone_forbidden');
|
||||
this._notifyError('error.call_microphone_forbidden', inbound);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -243,13 +368,17 @@ export class CallManager {
|
||||
|
||||
// Validate an explicitly-requested call stream: it must be `cameraID` itself
|
||||
// or one of its 2-way-audio dependencies.
|
||||
private _validateStream(cameraID: string, streamID: string): string | null {
|
||||
private _validateStream(
|
||||
cameraID: string,
|
||||
streamID: string,
|
||||
inbound: boolean,
|
||||
): string | null {
|
||||
const eligibleCameraIDs = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getAllDependentCameras(cameraID, '2-way-audio');
|
||||
if (!eligibleCameraIDs.has(streamID)) {
|
||||
this._notifyError('error.call_invalid_target');
|
||||
this._notifyError('error.call_invalid_target', inbound);
|
||||
return null;
|
||||
}
|
||||
return streamID;
|
||||
@@ -259,7 +388,11 @@ export class CallManager {
|
||||
// it's call-capable (keeps the user's substream selection intact). Else
|
||||
// fall back to the parent itself (if call-capable) or the first eligible
|
||||
// dependency. Returns null + notification if neither path finds a target.
|
||||
private _pickDefaultTarget(view: View, parentID: string): string | null {
|
||||
private _pickDefaultTarget(
|
||||
view: View,
|
||||
parentID: string,
|
||||
inbound: boolean,
|
||||
): string | null {
|
||||
const currentStream = getStreamCameraID(view, parentID);
|
||||
if (currentStream && this._hasCallCapability(currentStream)) {
|
||||
return currentStream;
|
||||
@@ -272,7 +405,7 @@ export class CallManager {
|
||||
.getAllDependentCameras(parentID, '2-way-audio'),
|
||||
];
|
||||
if (!candidates.length) {
|
||||
this._notifyError('error.call_no_two_way_audio');
|
||||
this._notifyError('error.call_no_two_way_audio', inbound);
|
||||
return null;
|
||||
}
|
||||
return candidates[0];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,11 @@ export interface CallSession {
|
||||
// The view from before the call started: a clone with `queryResults` dropped.
|
||||
// Used to undo the call when it ends.
|
||||
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.getPIPManager().initialize();
|
||||
this._api.getKeyboardStateManager().initialize();
|
||||
this._api.getCallManager().initialize();
|
||||
|
||||
// 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
|
||||
@@ -188,6 +189,8 @@ export class CardElementManager {
|
||||
// correctly and triggers that changed while detached are picked up.
|
||||
// Reset trigger state first to stop stale timers and clear condition state.
|
||||
this._api.getTriggersManager().reset();
|
||||
|
||||
this._api.getCallManager().uninitialize();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
|
||||
@@ -181,28 +181,26 @@ export class TriggersManager {
|
||||
|
||||
private async _triggerAction(ev: CameraEvent): Promise<void> {
|
||||
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;
|
||||
|
||||
// Early exit guard: If this is a high-fidelity event where we are certain
|
||||
// about new media, don't take action unless it's to change to live (Frigate
|
||||
// engine may pump out events where there's no new media to show). Other
|
||||
// trigger actions (e.g. media, update) do not make sense without having
|
||||
// some new media.
|
||||
if (
|
||||
// Skip the trigger action for a high-fidelity "no new media" event when
|
||||
// the configured action would change to a non-live view (Frigate may pump
|
||||
// out such events). `live`, `call`, and default-with-live remain valid
|
||||
// since they don't depend on media being available.
|
||||
const skipViewAction =
|
||||
ev.fidelity === 'high' &&
|
||||
!ev.snapshot &&
|
||||
!ev.clip &&
|
||||
!ev.review &&
|
||||
!(
|
||||
triggerAction === 'call' ||
|
||||
triggerAction === 'live' ||
|
||||
(triggerAction === 'default' && defaultView === 'live')
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
if (this._hasAllowableInteractionStateForAction()) {
|
||||
if (this._hasAllowableInteractionStateForAction() && !skipViewAction) {
|
||||
if (triggerAction === 'update') {
|
||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||
queryExecutorOptions: { useCache: false },
|
||||
@@ -220,6 +218,10 @@ export class TriggersManager {
|
||||
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') {
|
||||
// Choose the most appropriate media view based on what's available.
|
||||
// Priority: review > clip > snapshot
|
||||
@@ -229,10 +231,10 @@ export class TriggersManager {
|
||||
? 'clip'
|
||||
: ev.snapshot
|
||||
? 'snapshot'
|
||||
: /* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
|
||||
: /* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore next: unreachable due to early exit guard above -- @preserve */
|
||||
/* istanbul ignore next: unreachable due to `skipViewAction` above -- @preserve */
|
||||
if (view) {
|
||||
await this._api.getViewManager().setViewByParametersWithNewQuery({
|
||||
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
|
||||
.actions.untrigger;
|
||||
|
||||
@@ -263,8 +265,19 @@ export class TriggersManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._hasAllowableInteractionStateForAction()) {
|
||||
await this._api.getViewManager().setViewDefaultWithNewQuery();
|
||||
if (!this._hasAllowableInteractionStateForAction()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'default':
|
||||
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;
|
||||
}
|
||||
@@ -273,7 +286,7 @@ export class TriggersManager {
|
||||
this._deleteUntriggerDelayTimer(cameraID);
|
||||
this._deleteForceUntriggerTimer(cameraID);
|
||||
|
||||
await this._executeUntriggerAction();
|
||||
await this._executeUntriggerAction(cameraID);
|
||||
this._deleteStateIfIdle(cameraID);
|
||||
|
||||
this._setConditionStateIfNecessary();
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface CardAutomationsAPI {
|
||||
export interface CardCallAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getViewManager(): ViewManager;
|
||||
@@ -151,6 +152,7 @@ export interface CardDownloadAPI {
|
||||
|
||||
export interface CardElementAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCallManager(): CallManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
@@ -312,6 +314,7 @@ export interface CardStyleAPI {
|
||||
}
|
||||
|
||||
export interface CardTriggersAPI {
|
||||
getCallManager(): CallManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
|
||||
@@ -78,7 +78,6 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
||||
|
||||
const destroyProperties = [
|
||||
'direction',
|
||||
'dragEnabled',
|
||||
'dragFree',
|
||||
'loop',
|
||||
'plugins',
|
||||
@@ -87,6 +86,8 @@ export class AdvancedCameraCardCarousel extends LitElement {
|
||||
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
||||
this._carousel?.destroy();
|
||||
this._carousel = null;
|
||||
} else if (changedProps.has('dragEnabled') && this._carousel) {
|
||||
this._carousel.setDragEnabled(this.dragEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,40 @@ const microphoneConfigDefault = {
|
||||
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 = {
|
||||
button_size: 40,
|
||||
lock: true,
|
||||
ringtone: { ...ringtoneConfigDefault },
|
||||
unanswered_timeout_seconds: 60,
|
||||
};
|
||||
|
||||
const callConfigSchema = z.object({
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(callConfigDefault.button_size),
|
||||
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
|
||||
|
||||
@@ -90,10 +90,10 @@ export const triggersSchema = z.object({
|
||||
.object({
|
||||
interaction_mode: interactionModeSchema,
|
||||
trigger: z
|
||||
.enum(['default', 'live', 'media', 'none', 'update'])
|
||||
.enum(['call', 'default', 'live', 'media', 'none', 'update'])
|
||||
.default(viewConfigDefault.triggers.actions.trigger),
|
||||
untrigger: z
|
||||
.enum(['default', 'none'])
|
||||
.enum(['call', 'default', 'none'])
|
||||
.default(viewConfigDefault.triggers.actions.untrigger),
|
||||
})
|
||||
.default(viewConfigDefault.triggers.actions),
|
||||
|
||||
+10
-2
@@ -196,12 +196,12 @@ export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS =
|
||||
export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS =
|
||||
`${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_INTERACTION_MODE =
|
||||
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
|
||||
export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER =
|
||||
`${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const;
|
||||
export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER =
|
||||
`${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;
|
||||
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 =
|
||||
`${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_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 =
|
||||
`${CONF_LIVE}.controls.next_previous.auto_hide` as const;
|
||||
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
|
||||
|
||||
@@ -123,6 +123,10 @@ import {
|
||||
CONF_LIVE_CONTROLS_BUILTIN,
|
||||
CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE,
|
||||
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_SIZE,
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
@@ -967,6 +971,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
private _triggersActionsTrigger: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'call',
|
||||
label: localize('config.view.triggers.actions.triggers.call'),
|
||||
},
|
||||
{
|
||||
value: 'default',
|
||||
label: localize('config.view.triggers.actions.triggers.default'),
|
||||
@@ -987,6 +995,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
private _triggersActionsUntrigger: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'call',
|
||||
label: localize('config.view.triggers.actions.untriggers.call'),
|
||||
},
|
||||
{
|
||||
value: '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[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
@@ -3330,6 +3370,32 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_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, {
|
||||
min: BUTTON_SIZE_MIN,
|
||||
})}
|
||||
|
||||
@@ -401,7 +401,21 @@
|
||||
"lock": "Lock UI during an active call",
|
||||
"mute_audio": "Mute audio",
|
||||
"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",
|
||||
"unanswered_timeout_seconds": "Seconds before ending unanswered inbound calls (0=never)",
|
||||
"unmute_audio": "Unmute audio",
|
||||
"unmute_microphone": "Unmute microphone"
|
||||
},
|
||||
@@ -646,6 +660,7 @@
|
||||
"interaction_mode": "How to handle actions when the card has human interaction",
|
||||
"trigger": "Trigger action",
|
||||
"triggers": {
|
||||
"call": "Start a 2-way audio call",
|
||||
"default": "Change to or update default view",
|
||||
"live": "Change to or update live view",
|
||||
"media": "Change to the relevant media view for new media",
|
||||
@@ -653,6 +668,7 @@
|
||||
},
|
||||
"untrigger": "Untrigger action",
|
||||
"untriggers": {
|
||||
"call": "End a 2-way audio call",
|
||||
"default": "Change to default view/camera",
|
||||
"none": "No action"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@ export class CarouselController {
|
||||
private _startIndex: number;
|
||||
private _transitionEffect: TransitionEffect;
|
||||
private _loop: boolean;
|
||||
|
||||
// Whether a drag releases into free-scroll momentum (`true`) or snaps to the
|
||||
// nearest scroll snap (`false`).
|
||||
private _dragFree: boolean;
|
||||
|
||||
// Whether drag input is honored at all`.
|
||||
private _draggable: boolean;
|
||||
private _textDirection: TextDirection;
|
||||
private _wheelScrolling: boolean;
|
||||
@@ -101,6 +106,13 @@ export class CarouselController {
|
||||
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 => {
|
||||
const slides = getChildrenFromElement(this._parent);
|
||||
const slidesChanged = !isEqual(this._carousel.slideNodes(), slides);
|
||||
@@ -127,7 +139,10 @@ export class CarouselController {
|
||||
// assignments, which the stock watcher does not handle).
|
||||
watchSlides: false,
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,10 @@ it('should handle call_start action without a camera or stream', async () => {
|
||||
|
||||
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 () => {
|
||||
@@ -31,8 +34,8 @@ it('should handle call_start action with a camera and stream', async () => {
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getCallManager().start).toBeCalledWith(
|
||||
'camera.front',
|
||||
'camera.front_doorbell',
|
||||
);
|
||||
expect(api.getCallManager().start).toBeCalledWith({
|
||||
cameraID: 'camera.front',
|
||||
streamID: 'camera.front_doorbell',
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.getMediaLoadedInfoManager().initialize).toBeCalled();
|
||||
expect(api.getMicrophoneManager().initialize).toBeCalled();
|
||||
expect(api.getCallManager().initialize).toBeCalled();
|
||||
});
|
||||
|
||||
it('should disconnect', () => {
|
||||
@@ -209,6 +210,7 @@ describe('CardElementManager', () => {
|
||||
expect(api.getFullscreenManager().disconnect).toBeCalled();
|
||||
expect(api.getKeyboardStateManager().uninitialize).toBeCalled();
|
||||
expect(api.getActionsManager().uninitialize).toBeCalled();
|
||||
expect(api.getCallManager().uninitialize).toBeCalled();
|
||||
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
|
||||
});
|
||||
|
||||
|
||||
@@ -291,6 +291,55 @@ describe('TriggersManager', () => {
|
||||
expect(api.getViewManager().setViewDefaultWithNewQuery).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', () => {
|
||||
@@ -361,6 +410,38 @@ describe('TriggersManager', () => {
|
||||
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 () => {
|
||||
const api = createTriggerAPI();
|
||||
const manager = new TriggersManager(api);
|
||||
|
||||
@@ -94,6 +94,8 @@ describe('config defaults', () => {
|
||||
call: {
|
||||
button_size: 40,
|
||||
lock: true,
|
||||
ringtone: { type: 'chime', repeat: 0 },
|
||||
unanswered_timeout_seconds: 60,
|
||||
},
|
||||
next_previous: {
|
||||
auto_hide: ['call', 'casting'],
|
||||
|
||||
@@ -195,13 +195,47 @@ describe('CarouselController', () => {
|
||||
containScroll: 'trimSnaps',
|
||||
watchSlides: false,
|
||||
watchResize: true,
|
||||
watchDrag: false,
|
||||
watchDrag: expect.any(Function),
|
||||
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', () => {
|
||||
const children = createTestSlideNodes();
|
||||
const root = createRoot();
|
||||
|
||||
Reference in New Issue
Block a user