diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 456820a1..7fcac9c5 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -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 diff --git a/docs/configuration/view.md b/docs/configuration/view.md index 52d31abe..724f8ab3 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -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 diff --git a/docs/examples.md b/docs/examples.md index 04c2247a..ff7d1640 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -359,6 +359,46 @@ elements: ![Doorbell example](images/doorbell-example.gif 'Doorbell example :size=400') +### Inbound call on doorbell press + +This example uses [`view.triggers.actions.trigger: call`](configuration/view.md?id=triggers) to turn an dashboard into a phone-like ringer when somebody presses the doorbell. The intended deployment is a wall-mounted tablet sitting on the dashboard. + +The Frigate camera's stock event triggers (`occupancy`, `motion`, `events`) are explicitly turned off so casual motion doesn't make the card ring — only an actual doorbell press does. + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.front_door + live_provider: go2rtc + go2rtc: + modes: + - webrtc + triggers: + occupancy: false + motion: false + events: [] + entities: + - switch.door_bell +view: + default: live + triggers: + show_trigger_status: true + # Keeps the trigger "active" this long after the switch goes off. + # A doorbell button typically gives a brief ON pulse, so the chime + # would stop almost immediately if this were small -- this value + # is effectively how long the chime keeps ringing after the press. + untrigger_delay_seconds: 30 + actions: + # Call when triggered. + trigger: call + # On release (after the untrigger_delay): end the call if it is + # still ringing. An answered call survives this and must be ended + # manually. + untrigger: call + # Ring even when somebody is actively using the card. + interaction_mode: all +``` + ## Events from other cameras `dependencies.cameras` allows events/recordings for other cameras to be shown diff --git a/src/card-controller/actions/actions/call-start.ts b/src/card-controller/actions/actions/call-start.ts index 11751015..0e4b0e08 100644 --- a/src/card-controller/actions/actions/call-start.ts +++ b/src/card-controller/actions/actions/call-start.ts @@ -6,6 +6,9 @@ export class CallStartAction extends AdvancedCameraCardAction { 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, + }); } } diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index bbe0cc6e..b0573001 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -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 { + // Returns true iff the requested call is active after this returns, false + // otherwise. + public async start(options?: { + cameraID?: string; + streamID?: string; + inbound?: boolean; + }): Promise { + 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 { + private async _connectMicrophone(inbound: boolean): Promise { 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]; diff --git a/src/card-controller/call/ringtone.ts b/src/card-controller/call/ringtone.ts new file mode 100644 index 00000000..794d8233 --- /dev/null +++ b/src/card-controller/call/ringtone.ts @@ -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(); + +export class Ringtone { + private _tone: Tone | null = null; + private readonly lock: Set; + + // 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 = 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; + } + } +} diff --git a/src/card-controller/call/tones/arpeggio.ts b/src/card-controller/call/tones/arpeggio.ts new file mode 100644 index 00000000..032faad6 --- /dev/null +++ b/src/card-controller/call/tones/arpeggio.ts @@ -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); + } +} diff --git a/src/card-controller/call/tones/base.ts b/src/card-controller/call/tones/base.ts new file mode 100644 index 00000000..45735ac3 --- /dev/null +++ b/src/card-controller/call/tones/base.ts @@ -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; +} diff --git a/src/card-controller/call/tones/bell.ts b/src/card-controller/call/tones/bell.ts new file mode 100644 index 00000000..345b8823 --- /dev/null +++ b/src/card-controller/call/tones/bell.ts @@ -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, + }); + } +} diff --git a/src/card-controller/call/tones/chime.ts b/src/card-controller/call/tones/chime.ts new file mode 100644 index 00000000..7b93d475 --- /dev/null +++ b/src/card-controller/call/tones/chime.ts @@ -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); + } +} diff --git a/src/card-controller/call/tones/custom.ts b/src/card-controller/call/tones/custom.ts new file mode 100644 index 00000000..8db2faed --- /dev/null +++ b/src/card-controller/call/tones/custom.ts @@ -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?.(); + } +} diff --git a/src/card-controller/call/tones/melody.ts b/src/card-controller/call/tones/melody.ts new file mode 100644 index 00000000..fb50b7eb --- /dev/null +++ b/src/card-controller/call/tones/melody.ts @@ -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, + }); + } +} diff --git a/src/card-controller/call/tones/types.ts b/src/card-controller/call/tones/types.ts new file mode 100644 index 00000000..432fe209 --- /dev/null +++ b/src/card-controller/call/tones/types.ts @@ -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; +} diff --git a/src/card-controller/call/tones/westminster.ts b/src/card-controller/call/tones/westminster.ts new file mode 100644 index 00000000..dcaa6941 --- /dev/null +++ b/src/card-controller/call/tones/westminster.ts @@ -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); + } +} diff --git a/src/card-controller/call/types.ts b/src/card-controller/call/types.ts index 832515c7..e859941e 100644 --- a/src/card-controller/call/types.ts +++ b/src/card-controller/call/types.ts @@ -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; } diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 7240212f..9d169ff8 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -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() diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index ebee0507..760aaec4 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -181,28 +181,26 @@ export class TriggersManager { private async _triggerAction(ev: CameraEvent): Promise { 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 { + private async _executeUntriggerAction(cameraID: string): Promise { 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(); diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index c2193f6e..48556e2f 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; diff --git a/src/components/carousel.ts b/src/components/carousel.ts index b9987e6c..74527420 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -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); } } diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index 67efed79..8bbc0be8 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -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; + 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 diff --git a/src/config/schema/view.ts b/src/config/schema/view.ts index c2df51df..87e26c8a 100644 --- a/src/config/schema/view.ts +++ b/src/config/schema/view.ts @@ -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), diff --git a/src/const.ts b/src/const.ts index e18f8d6d..739fc74f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -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 = diff --git a/src/editor.ts b/src/editor.ts index 132315db..8d31b0b2 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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, })} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 0e35d38d..5a6e2809 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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" } diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts index 7898cf4c..2f65b1e0 100644 --- a/src/utils/embla/carousel-controller.ts +++ b/src/utils/embla/carousel-controller.ts @@ -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, }, diff --git a/tests/card-controller/actions/actions/call-start.test.ts b/tests/card-controller/actions/actions/call-start.test.ts index 837db4c5..ce5cad4f 100644 --- a/tests/card-controller/actions/actions/call-start.test.ts +++ b/tests/card-controller/actions/actions/call-start.test.ts @@ -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', + }); }); diff --git a/tests/card-controller/call/manager.test.ts b/tests/card-controller/call/manager.test.ts index 60281162..45c4ce52 100644 --- a/tests/card-controller/call/manager.test.ts +++ b/tests/card-controller/call/manager.test.ts @@ -1,19 +1,43 @@ -import { assert, describe, expect, it, vi } from 'vitest'; +// @vitest-environment jsdom + +import { PartialDeep } from 'type-fest'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; import { CameraManagerStore } from '../../../src/camera-manager/store'; import { CallManager } from '../../../src/card-controller/call/manager'; +import { Ringtone } from '../../../src/card-controller/call/ringtone'; import { CardController } from '../../../src/card-controller/controller'; import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream'; import { ConditionStateChange } from '../../../src/conditions/types'; +import { RingtoneConfig } from '../../../src/config/schema/live'; +import { AdvancedCameraCardConfig } from '../../../src/config/schema/types'; import { View } from '../../../src/view/view'; import { createCameraConfig, createCameraManager, createCapabilities, createCardAPI, + createConfig, createStore, createView, } from '../../test-utils'; +// Replace Ringtone with a fresh `mock()` per construction so each +// CallManager gets an isolated, type-safe ringtone we can assert on. The +// real Ringtone creates an AudioContext, which we never want in tests. +vi.mock('../../../src/card-controller/call/ringtone', () => ({ + Ringtone: vi.fn().mockImplementation(() => mock()), +})); + +// Each test creates a new CallManager which constructs a new Ringtone, so the +// most recent constructor result is always this test's mock. +const getRingtone = (): Ringtone => { + const results = vi.mocked(Ringtone).mock.results; + const last = results.at(-1); + assert(last); + return last.value; +}; + // A store with a single 2-way-audio-capable camera. const createCallableStore = (cameraID = 'camera.office'): CameraManagerStore => createStore([ @@ -29,6 +53,8 @@ const createAPI = (options?: { microphoneSupported?: boolean; microphoneForbidden?: boolean; microphoneConnected?: boolean; + microphoneMuted?: boolean; + config?: PartialDeep; }): CardController => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(options?.view ?? null); @@ -44,6 +70,14 @@ const createAPI = (options?: { vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue( options?.microphoneConnected ?? true, ); + vi.mocked(api.getMicrophoneManager().isMuted).mockReturnValue( + options?.microphoneMuted ?? true, + ); + if (options?.config) { + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig(options.config), + ); + } return api; }; @@ -66,7 +100,7 @@ describe('isActive', () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); expect(manager.isActive()).toBe(true); // The call runs on the parent camera's own stream, so callCameraID is @@ -74,6 +108,8 @@ describe('isActive', () => { expect(manager.getCall()).toEqual({ cameraID: 'camera.office', previousView: expect.any(View), + inbound: false, + answered: false, }); expect(manager.getCall()?.previousView?.view).toBe('live'); }); @@ -83,7 +119,7 @@ describe('start', () => { it('should do nothing without a view camera', async () => { const api = createAPI({ view: createView({ camera: null }) }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -92,9 +128,9 @@ describe('start', () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); - await manager.start(); + expect(await manager.start()).toBe(true); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -102,7 +138,7 @@ describe('start', () => { it('should start a call on the selected camera', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(true); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ modifiers: [expect.any(SubstreamViewModifier)], @@ -116,7 +152,7 @@ describe('start', () => { view: createView({ camera: 'camera.office', view: 'clips' }), }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(true); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ params: { view: 'live', camera: 'camera.office' }, @@ -130,7 +166,7 @@ describe('start', () => { view: createView({ camera: 'camera.office', view: 'live' }), }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(true); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ modifiers: [expect.any(SubstreamViewModifier)], @@ -147,7 +183,7 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); const call = manager.getCall(); expect(call?.previousView?.view).toBe('clips'); @@ -162,7 +198,7 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); const call = manager.getCall(); expect(call?.previousView?.view).toBe('live'); @@ -185,8 +221,8 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start(); - await manager.start('camera.garage'); + expect(await manager.start()).toBe(true); + expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); const call = manager.getCall(); expect(call?.cameraID).toBe('camera.garage'); @@ -201,7 +237,7 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start('camera.office'); + expect(await manager.start({ cameraID: 'camera.office' })).toBe(true); const call = manager.getCall(); expect(call?.cameraID).toBe('camera.office'); @@ -230,7 +266,7 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start('camera.garage'); + expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); const call = manager.getCall(); expect(call?.cameraID).toBe('camera.garage'); @@ -262,7 +298,9 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start('camera.office', 'camera.doorbell'); + expect( + await manager.start({ cameraID: 'camera.office', streamID: 'camera.doorbell' }), + ).toBe(true); const call = manager.getCall(); expect(call?.cameraID).toBe('camera.office'); @@ -273,7 +311,7 @@ describe('start', () => { it('should abort when the requested camera is not a live camera', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); - await new CallManager(api).start('camera.unknown'); + expect(await new CallManager(api).start({ cameraID: 'camera.unknown' })).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); @@ -294,7 +332,12 @@ describe('start', () => { ]), }); - await new CallManager(api).start('camera.office', 'camera.unrelated'); + expect( + await new CallManager(api).start({ + cameraID: 'camera.office', + streamID: 'camera.unrelated', + }), + ).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); @@ -316,8 +359,8 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start(); - await manager.start('camera.garage'); + expect(await manager.start()).toBe(true); + expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); const call = manager.getCall(); expect(call?.cameraID).toBe('camera.garage'); @@ -362,8 +405,12 @@ describe('start', () => { ); const manager = new CallManager(api); - await manager.start('camera.office', 'camera.doorbell'); - await manager.start('camera.office', 'camera.intercom'); + expect( + await manager.start({ cameraID: 'camera.office', streamID: 'camera.doorbell' }), + ).toBe(true); + expect( + await manager.start({ cameraID: 'camera.office', streamID: 'camera.intercom' }), + ).toBe(true); // The restarted call carries the new stream; the recorded pre-call view // keeps the genuine pre-call substream (none -- the camera's own stream), not the @@ -384,7 +431,7 @@ describe('start', () => { ]), }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); @@ -409,7 +456,7 @@ describe('start', () => { }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ modifiers: [expect.any(SubstreamViewModifier)], @@ -438,7 +485,7 @@ describe('start', () => { ]), }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(true); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ modifiers: [expect.any(SubstreamViewModifier)], @@ -452,7 +499,7 @@ describe('start', () => { microphoneSupported: false, }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); @@ -464,7 +511,7 @@ describe('start', () => { microphoneForbidden: true, }); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); @@ -477,7 +524,7 @@ describe('start', () => { }); vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(true); expect(api.getMicrophoneManager().connect).toBeCalled(); expect(api.getViewManager().setViewByParameters).toBeCalled(); @@ -490,18 +537,132 @@ describe('start', () => { }); vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error()); - await new CallManager(api).start(); + expect(await new CallManager(api).start()).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getNotificationManager().setNotification).toBeCalled(); }); }); +// An inbound start request must not displace a call the user cares about +// (manual call, or an answered inbound call). Newer unanswered inbound rings +// still replace older ones. Manual (user-initiated) start keeps its full +// supersede authority. +describe('inbound supersede policy', () => { + const twoCameraStore = createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.garage', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]); + + it('should skip an inbound start when an answered call is active on another camera', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: twoCameraStore, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + // Answer the call. + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + expect(manager.getCall()?.answered).toBe(true); + + expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe( + false, + ); + + // Call still on the original camera, untouched. + expect(manager.getCall()?.cameraID).toBe('camera.office'); + expect(manager.getCall()?.answered).toBe(true); + }); + + it('should skip an inbound start request when a manual call is active on another camera', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: twoCameraStore, + }); + const manager = new CallManager(api); + expect(await manager.start()).toBe(true); + expect(manager.getCall()?.inbound).toBe(false); + + expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe( + false, + ); + + expect(manager.getCall()?.cameraID).toBe('camera.office'); + expect(manager.getCall()?.inbound).toBe(false); + }); + + it('should supersede an unanswered inbound call with another inbound on a different camera', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: twoCameraStore, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + expect(manager.getCall()?.answered).toBe(false); + + expect(await manager.start({ cameraID: 'camera.garage', inbound: true })).toBe(true); + + expect(manager.getCall()?.cameraID).toBe('camera.garage'); + expect(manager.getCall()?.inbound).toBe(true); + }); + + it('should let a manual start supersede an answered inbound call', async () => { + // Manual start (inbound: false) retains full supersede authority -- + // explicit user intent wins. + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: twoCameraStore, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + + expect(await manager.start({ cameraID: 'camera.garage' })).toBe(true); + + expect(manager.getCall()?.cameraID).toBe('camera.garage'); + expect(manager.getCall()?.inbound).toBe(false); + }); +}); + describe('end', () => { it('should do nothing when no call is active', () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); - new CallManager(api).end(); + expect(new CallManager(api).end()).toBe(false); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); @@ -509,10 +670,10 @@ describe('end', () => { it('should end an active call', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); - manager.end(); + expect(manager.end()).toBe(true); expect(manager.isActive()).toBe(false); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ @@ -540,10 +701,10 @@ describe('end', () => { ]), }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); - manager.end(); + expect(manager.end()).toBe(true); // The recorded pre-call substream (`camera.sub`) is reinstated. expect(api.getViewManager().setViewByParameters).toBeCalledWith({ @@ -557,9 +718,9 @@ describe('end', () => { view: createView({ camera: 'camera.office', view: 'clips' }), }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); - manager.end(); + expect(manager.end()).toBe(true); expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({ baseView: expect.any(View), @@ -576,10 +737,10 @@ describe('end', () => { view: createView({ camera: 'camera.office', view: 'live' }), }); const manager = new CallManager(api); - await manager.start(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); - manager.end(); + expect(manager.end()).toBe(true); // No navigation: only the substream is undone. expect(api.getViewManager().setViewByParameters).toBeCalledWith({ @@ -595,9 +756,9 @@ describe('end', () => { store: createCallableStore('camera.office'), }); const manager = new CallManager(api); - await manager.start('camera.office'); + expect(await manager.start({ cameraID: 'camera.office' })).toBe(true); - manager.end(); + expect(manager.end()).toBe(true); const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) .mock.calls[0]?.[0]; @@ -606,11 +767,172 @@ describe('end', () => { }); }); +// `endIf` is the predicate-driven conditional end: ends the active call iff +// every supplied option matches its corresponding field on the session. +// Fields left `undefined` are not gated on. +describe('endIf', () => { + it('should no-op when there is no active call', () => { + const api = createAPI(); + const manager = new CallManager(api); + + expect(manager.endIf({ cameraID: 'camera.office' })).toBe(false); + expect(manager.isActive()).toBe(false); + }); + + it('should end unconditionally when no options are supplied', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + expect(await manager.start()).toBe(true); + expect(manager.isActive()).toBe(true); + + expect(manager.endIf({})).toBe(true); + + expect(manager.isActive()).toBe(false); + }); + + describe('cameraID gate', () => { + it('should end when cameraID matches', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + expect(await manager.start()).toBe(true); + + expect(manager.endIf({ cameraID: 'camera.office' })).toBe(true); + + expect(manager.isActive()).toBe(false); + }); + + it('should not end when cameraID does not match', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + expect(await manager.start()).toBe(true); + + expect(manager.endIf({ cameraID: 'camera.other' })).toBe(false); + + expect(manager.isActive()).toBe(true); + }); + }); + + describe('inbound gate', () => { + it('should end an inbound call when inbound: true is required', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.endIf({ inbound: true })).toBe(true); + + expect(manager.isActive()).toBe(false); + }); + + it('should not end a manual call when inbound: true is required', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + expect(await manager.start()).toBe(true); + + expect(manager.endIf({ inbound: true })).toBe(false); + + expect(manager.isActive()).toBe(true); + }); + + it('should end a manual call when inbound: false is required', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + expect(await manager.start()).toBe(true); + + expect(manager.endIf({ inbound: false })).toBe(true); + + expect(manager.isActive()).toBe(false); + }); + }); + + describe('answered gate', () => { + it('should end an unanswered call when answered: false is required', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + expect(manager.getCall()?.answered).toBe(false); + + expect(manager.endIf({ answered: false })).toBe(true); + + expect(manager.isActive()).toBe(false); + }); + + it('should not end an answered call when answered: false is required', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + expect(manager.getCall()?.answered).toBe(true); + + expect(manager.endIf({ answered: false })).toBe(false); + + expect(manager.isActive()).toBe(true); + }); + }); + + describe('combined gates (the trigger-untrigger predicate)', () => { + // The single composite predicate the `untrigger: 'call'` action uses: + // end iff it's an inbound, unanswered call on this same camera. + const triggerPredicate = (cameraID: string) => ({ + cameraID, + inbound: true, + answered: false, + }); + + it('should end an unanswered inbound call on the matching camera', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.endIf(triggerPredicate('camera.office'))).toBe(true); + + expect(manager.isActive()).toBe(false); + }); + + it('should not end when the camera differs', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.endIf(triggerPredicate('camera.other'))).toBe(false); + + expect(manager.isActive()).toBe(true); + }); + + it('should not end a manual call even on the matching camera', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start()).toBe(true); + + expect(manager.endIf(triggerPredicate('camera.office'))).toBe(false); + + expect(manager.isActive()).toBe(true); + }); + }); +}); + describe('condition state changes', () => { it('should end the call when the selected camera changes away', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + manager.initialize(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); getConditionStateListener(api)({ @@ -631,7 +953,8 @@ describe('condition state changes', () => { view: createView({ camera: 'camera.office', view: 'clips' }), }); const manager = new CallManager(api); - await manager.start(); + manager.initialize(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); getConditionStateListener(api)({ @@ -647,7 +970,8 @@ describe('condition state changes', () => { it('should end the call when the view leaves live', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + manager.initialize(); + expect(await manager.start()).toBe(true); vi.mocked(api.getViewManager().setViewByParameters).mockClear(); getConditionStateListener(api)({ @@ -666,7 +990,8 @@ describe('condition state changes', () => { it('should keep the call when the selected camera is unchanged', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + manager.initialize(); + expect(await manager.start()).toBe(true); getConditionStateListener(api)({ old: { camera: 'camera.office' }, @@ -679,7 +1004,7 @@ describe('condition state changes', () => { it('should no-op when no call is active', () => { const api = createAPI(); - new CallManager(api); + new CallManager(api).initialize(); getConditionStateListener(api)({ old: {}, @@ -693,7 +1018,8 @@ describe('condition state changes', () => { it('should end the call when the substream changes away', async () => { const api = createAPI({ view: createView({ camera: 'camera.office' }) }); const manager = new CallManager(api); - await manager.start(); + manager.initialize(); + expect(await manager.start()).toBe(true); getConditionStateListener(api)({ old: { camera: 'camera.office', view: 'live' }, @@ -722,7 +1048,8 @@ describe('condition state changes', () => { ]), }); const manager = new CallManager(api); - await manager.start(); + manager.initialize(); + expect(await manager.start()).toBe(true); getConditionStateListener(api)({ old: { camera: 'camera.office', substreamID: 'camera.sub' }, @@ -732,4 +1059,613 @@ describe('condition state changes', () => { expect(manager.isActive()).toBe(true); }); + + it('should ignore unrelated setState calls during a camera supersede', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + await manager.start({ inbound: true }); + + // Simulate the new session having been swapped to a different camera. + const call = manager.getCall(); + assert(call); + call.cameraID = 'camera.garage'; + + // Fire a setState that does NOT change view/camera/substream -- only + // `mediaLoadedInfo`. The listener must not end the call. + getConditionStateListener(api)({ + old: { view: 'live', camera: 'camera.office' }, + change: { mediaLoadedInfo: null }, + new: { view: 'live', camera: 'camera.office', mediaLoadedInfo: null }, + }); + + expect(manager.isActive()).toBe(true); + }); +}); + +describe('initialize / uninitialize', () => { + it('should not register the condition state listener until initialize', () => { + const api = createAPI(); + new CallManager(api); + + expect(api.getConditionStateManager().addListener).not.toBeCalled(); + }); + + it('should register the condition state listener on initialize', () => { + const api = createAPI(); + const manager = new CallManager(api); + + manager.initialize(); + + expect(api.getConditionStateManager().addListener).toBeCalled(); + }); + + it('should remove the condition state listener on uninitialize', () => { + const api = createAPI(); + const manager = new CallManager(api); + manager.initialize(); + const listener = getConditionStateListener(api); + + manager.uninitialize(); + + expect(api.getConditionStateManager().removeListener).toBeCalledWith(listener); + }); + + it('should tear down any active call session on uninitialize', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start()).toBe(true); + expect(manager.isActive()).toBe(true); + + manager.uninitialize(); + + expect(manager.isActive()).toBe(false); + expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false }); + }); + + it('should ignore further condition state changes after uninitialize', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start()).toBe(true); + const listener = getConditionStateListener(api); + + manager.uninitialize(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + // Even if a stale ref to the listener fires it, the call session is + // already torn down so no view-change happens. + listener({ + old: { camera: 'camera.office', view: 'live' }, + change: { camera: 'camera.other' }, + new: { camera: 'camera.other', view: 'live' }, + }); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); +}); + +// `inbound: true` suppresses each of the preflight/validation notifications. +// Every path that would call `_notifyError` is exercised under both the +// non-inbound case (notification surfaced) and the inbound case (silent). The +// non-inbound coverage already lives in the `start` describe above; here we +// assert the inbound paths stay silent. +describe('inbound option', () => { + it('should suppress notification when the camera lacks live capability', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: false, '2-way-audio': true }), + }, + ]), + }); + + expect(await new CallManager(api).start({ inbound: true })).toBe(false); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should suppress notification when the microphone is unsupported', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneSupported: false, + }); + + expect(await new CallManager(api).start({ inbound: true })).toBe(false); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should suppress notification when the microphone is forbidden', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneForbidden: true, + }); + + expect(await new CallManager(api).start({ inbound: true })).toBe(false); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should suppress notification when microphone connect rejects', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + }); + vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error('denied')); + + expect(await new CallManager(api).start({ inbound: true })).toBe(false); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should suppress notification when an explicit stream is not 2-way audio', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + + expect( + await new CallManager(api).start({ + inbound: true, + streamID: 'camera.unrelated', + }), + ).toBe(false); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should suppress notification when no stream supports 2-way audio', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': false }), + }, + ]), + }); + + expect(await new CallManager(api).start({ inbound: true })).toBe(false); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + + it('should record the call as inbound on the session', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.getCall()?.inbound).toBe(true); + expect(manager.getCall()?.answered).toBe(false); + }); +}); + +// Answered tracking: the first muted->unmuted microphone transition during an +// inbound call flips `answered` to true (once; later mute/unmute cycles do not +// flip it back) and stops the ringtone / cancels the unanswered timer. +describe('answered tracking', () => { + const inboundConfig = { + live: { controls: { call: { ringtone: { type: 'chime' as const } } } }, + }; + + it('should not be answered immediately after an inbound start', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.getCall()?.answered).toBe(false); + }); + + it('should mark answered on a muted->unmuted microphone transition', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + + expect(manager.getCall()?.answered).toBe(true); + }); + + it('should not flip answered back when the user re-mutes after answering', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + const listener = getConditionStateListener(api); + + listener({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + expect(manager.getCall()?.answered).toBe(true); + + listener({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + change: { microphone: { connected: true, muted: true, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + }); + + expect(manager.getCall()?.answered).toBe(true); + }); + + it('should not mark non-inbound calls answered on un-mute', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start()).toBe(true); + + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + + expect(manager.getCall()?.answered).toBe(false); + }); + + it('should stop the ringtone on answer', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + vi.mocked(getRingtone().stop).mockClear(); + + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + + expect(getRingtone().stop).toBeCalled(); + }); + + it('should treat an inbound call as already-answered when the mic is already un-muted', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneMuted: false, + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.getCall()?.answered).toBe(true); + expect(getRingtone().start).not.toBeCalled(); + }); + + it('should not arm the unanswered timer when the mic is already un-muted', async () => { + vi.useFakeTimers(); + try { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneMuted: false, + config: { + live: { controls: { call: { unanswered_timeout_seconds: 60 } } }, + }, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('should not mark a manual (non-inbound) call as answered even if the mic is un-muted', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneMuted: false, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start()).toBe(true); + + // `answered` only carries meaning alongside `inbound`, so for manual + // calls it stays at its default to make the intent explicit. + expect(manager.getCall()?.answered).toBe(false); + }); +}); + +// Ringtone integration: started only for inbound + unanswered + a configured +// ringtone other than 'none'; stopped on end / uninitialize. +describe('ringtone', () => { + it('should start the ringtone for an inbound call with a configured tone', async () => { + const ringtone: RingtoneConfig = { type: 'chime', repeat: 0 }; + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: { live: { controls: { call: { ringtone: ringtone } } } }, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start({ inbound: true })).toBe(true); + + expect(getRingtone().start).toBeCalledWith(expect.objectContaining(ringtone)); + }); + + it('should not start the ringtone for a non-inbound call', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start()).toBe(true); + + expect(getRingtone().start).not.toBeCalled(); + }); + + it("should not start the ringtone when type is 'none'", async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: { live: { controls: { call: { ringtone: { type: 'none' } } } } }, + }); + const manager = new CallManager(api); + manager.initialize(); + + expect(await manager.start({ inbound: true })).toBe(true); + + expect(getRingtone().start).not.toBeCalled(); + }); + + it('should stop the ringtone when the call ends', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + vi.mocked(getRingtone().stop).mockClear(); + + expect(manager.end()).toBe(true); + + expect(getRingtone().stop).toBeCalled(); + }); + + it('should stop the ringtone on uninitialize', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: { live: { controls: { call: { ringtone: { type: 'chime' } } } } }, + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + vi.mocked(getRingtone().stop).mockClear(); + + manager.uninitialize(); + + expect(getRingtone().stop).toBeCalled(); + }); +}); + +// Unanswered-call timeout: when configured, arms a timer on inbound start, +// fires `end()` if no answer arrives, and cancels on answer, explicit end, or +// uninitialize. +describe('unanswered timeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + const inboundConfig = (unanswered_timeout_seconds: number) => ({ + live: { controls: { call: { unanswered_timeout_seconds } } }, + }); + + it('should auto-end an unanswered inbound call after the timeout', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig(60), + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + expect(manager.isActive()).toBe(true); + + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(false); + }); + + it('should not arm the timer when the timeout is 0', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig(0), + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(true); + }); + + it('should not arm the timer for non-inbound calls', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig(60), + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start()).toBe(true); + + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(true); + }); + + it('should cancel the timer when the call is answered', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig(60), + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + getConditionStateListener(api)({ + old: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: true, forbidden: false }, + }, + change: { microphone: { connected: true, muted: false, forbidden: false } }, + new: { + camera: 'camera.office', + view: 'live', + microphone: { connected: true, muted: false, forbidden: false }, + }, + }); + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(true); + }); + + it('should cancel the timer on explicit end', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig(60), + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + expect(manager.end()).toBe(true); + // The timer firing after end() would be a no-op (no active call) -- the + // important thing is that it does not throw or affect any state. + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(false); + }); + + it('should cancel the timer on uninitialize', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig(60), + }); + const manager = new CallManager(api); + manager.initialize(); + expect(await manager.start({ inbound: true })).toBe(true); + + manager.uninitialize(); + vi.advanceTimersByTime(60_000); + + expect(manager.isActive()).toBe(false); + }); +}); + +// `start()` calls `setState({ call: true })` to broadcast the new call status; +// a listener that responds by navigating away will fire the manager's own +// condition listener and end the call before `start()` returns. Verify the +// post-setState re-read of the session prevents follow-up work (ringtone / +// unanswered timer) on a session that is already gone. +describe('session end during setState', () => { + const inboundConfig = { + live: { + controls: { + call: { + ringtone: { type: 'chime' as const }, + unanswered_timeout_seconds: 60, + }, + }, + }, + }; + + it('should skip ringtone and unanswered timer when a listener ends the call', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + config: inboundConfig, + }); + const manager = new CallManager(api); + manager.initialize(); + const listener = getConditionStateListener(api); + + vi.mocked(api.getConditionStateManager().setState).mockImplementation((state) => { + // Simulate a downstream listener that responds to `call: true` by + // navigating away. The manager's own listener then ends the call, + // nulling the session before `start()` finishes. + if (state.call === true) { + listener({ + old: { camera: 'camera.office', view: 'live' }, + change: { view: 'clips' }, + new: { camera: 'camera.office', view: 'clips' }, + }); + } + return true; + }); + + expect(await manager.start({ inbound: true })).toBe(false); + + expect(getRingtone().start).not.toBeCalled(); + expect(manager.isActive()).toBe(false); + }); }); diff --git a/tests/card-controller/call/ringtone.test.ts b/tests/card-controller/call/ringtone.test.ts new file mode 100644 index 00000000..a9216208 --- /dev/null +++ b/tests/card-controller/call/ringtone.test.ts @@ -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()), +})); +vi.mock('../../../src/card-controller/call/tones/westminster', () => ({ + WestminsterTone: vi.fn().mockImplementation(() => mock()), +})); +vi.mock('../../../src/card-controller/call/tones/arpeggio', () => ({ + ArpeggioTone: vi.fn().mockImplementation(() => mock()), +})); +vi.mock('../../../src/card-controller/call/tones/melody', () => ({ + MelodyTone: vi.fn().mockImplementation(() => mock()), +})); +vi.mock('../../../src/card-controller/call/tones/custom', () => ({ + CustomTone: vi.fn().mockImplementation(() => mock()), +})); + +// Returns the most recently constructed instance of a mocked class. +const lastInstance = (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(); + 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(); + 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(); + 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(); + 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(); + 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(); + vi.mocked(tone.start).mockImplementation((handler) => { + finishedHandler = handler; + }); + return tone; + }); + const lock = new Set(); + 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(); + 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(); + }); +}); diff --git a/tests/card-controller/call/tones/arpeggio.test.ts b/tests/card-controller/call/tones/arpeggio.test.ts new file mode 100644 index 00000000..19bde782 --- /dev/null +++ b/tests/card-controller/call/tones/arpeggio.test.ts @@ -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), + ); + } + }); +}); diff --git a/tests/card-controller/call/tones/base.test.ts b/tests/card-controller/call/tones/base.test.ts new file mode 100644 index 00000000..897e7c24 --- /dev/null +++ b/tests/card-controller/call/tones/base.test.ts @@ -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(); + }); +}); diff --git a/tests/card-controller/call/tones/chime.test.ts b/tests/card-controller/call/tones/chime.test.ts new file mode 100644 index 00000000..cd8225ec --- /dev/null +++ b/tests/card-controller/call/tones/chime.test.ts @@ -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); + }); + }); +}); diff --git a/tests/card-controller/call/tones/custom.test.ts b/tests/card-controller/call/tones/custom.test.ts new file mode 100644 index 00000000..18b29646 --- /dev/null +++ b/tests/card-controller/call/tones/custom.test.ts @@ -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(); + }); +}); diff --git a/tests/card-controller/call/tones/melody.test.ts b/tests/card-controller/call/tones/melody.test.ts new file mode 100644 index 00000000..b50467ff --- /dev/null +++ b/tests/card-controller/call/tones/melody.test.ts @@ -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); + }); +}); diff --git a/tests/card-controller/call/tones/test-utils.ts b/tests/card-controller/call/tones/test-utils.ts new file mode 100644 index 00000000..c61a37ec --- /dev/null +++ b/tests/card-controller/call/tones/test-utils.ts @@ -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; + audioContextCtor: Mock<[], MockProxy>; + + // Filled in the order `createOscillator()` / `createGain()` were called. + oscillators: MockProxy[]; + gains: MockProxy[]; + + // gainParams[i] is the AudioParam exposed by `gains[i].gain` — kept as a + // parallel array because `mock()` doesn't auto-populate the + // AudioParam interface as a callable deep mock (we wire it up by hand). + gainParams: MockProxy[]; +} + +// 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(); + audio.oscillators = []; + audio.gains = []; + audio.gainParams = []; + + vi.mocked(audio.audioContext.createOscillator).mockImplementation(() => { + const oscillator = mock(); + // `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(); + const gainParam = mock(); + 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; +}; diff --git a/tests/card-controller/call/tones/westminster.test.ts b/tests/card-controller/call/tones/westminster.test.ts new file mode 100644 index 00000000..6517413c --- /dev/null +++ b/tests/card-controller/call/tones/westminster.test.ts @@ -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); + }); +}); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index e4d25f1c..27ad3500 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -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'); }); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 9fb6ade7..d0f3a28e 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -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); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 508980c3..66bc1f68 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -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'], diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts index 8cd8b8bd..cfee18de 100644 --- a/tests/utils/embla/carousel-controller.test.ts +++ b/tests/utils/embla/carousel-controller.test.ts @@ -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();