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; } } }