import type { AudioSession as AudioSessionHybrid } from '../specs/AudioSession.nitro'; import { AviationError } from '../errors'; export type AudioFocusPolicy = 'exclusive' | 'mixWithOthers'; export interface AudioFocusHandle { readonly isActive: boolean; request(): Promise; release(): Promise; dispose(): Promise; } export interface AudioFocusArbiter { createHandle(owner: AudioFocusOwner): AudioFocusHandle; getActiveOwnerId(): string | undefined; dispose(): Promise; } export interface AudioFocusOwner { readonly playerId: string; readonly session: AudioSessionHybrid; /** Optional mirror of handle.isActive; consumers usually just read that. */ onActiveChanged?(active: boolean): void; } interface AudioFocusArbiterOptions { policy: AudioFocusPolicy; } export function createAudioFocusArbiter( options: AudioFocusArbiterOptions ): AudioFocusArbiter { return new DefaultAudioFocusArbiter(options.policy); } class DefaultAudioFocusArbiter implements AudioFocusArbiter { // Two levels of "active" live here, and keeping them apart is the whole // design. `logicallyActiveHandles` is the arbiter's answer to "who owns // playback": exactly one handle under 'exclusive', several under // 'mixWithOthers'. `nativeActivatedHandles` records every handle whose OS // session was actually activated; under 'mixWithOthers' a released handle // stays in it until the LAST logical owner releases, at which point the // sweep below deactivates everyone else's session. A disposed handle can // therefore still sit in `nativeActivatedHandles` after leaving `handles` // — which is why dispose() unions both sets instead of iterating handles. private readonly handles = new Set(); private readonly logicallyActiveHandles = new Set(); private readonly nativeActivatedHandles = new Set(); private activeOwner: AudioFocusHandleImpl | undefined; private disposed = false; constructor(private readonly policy: AudioFocusPolicy) {} createHandle(owner: AudioFocusOwner): AudioFocusHandle { if (this.disposed) { throw new AviationError( 'DISPOSED', '[Aviation] Cannot create an audio focus handle after dispose.' ); } const handle = new AudioFocusHandleImpl(owner, this); this.handles.add(handle); return handle; } getActiveOwnerId(): string | undefined { return this.activeOwner?.owner.playerId; } async dispose(): Promise { if (this.disposed) return; this.disposed = true; const handles = Array.from( new Set([...this.handles, ...this.nativeActivatedHandles]) ); this.handles.clear(); this.logicallyActiveHandles.clear(); this.nativeActivatedHandles.clear(); this.activeOwner = undefined; await Promise.all(handles.map((handle) => handle.disposeFromArbiter())); } async request(handle: AudioFocusHandleImpl): Promise { this.assertLive(handle); if (this.policy === 'exclusive') { const previous = this.activeOwner; if (previous && previous !== handle) { await previous.releaseFromArbiter({ deactivateNative: true }); this.logicallyActiveHandles.delete(previous); this.nativeActivatedHandles.delete(previous); } } await handle.activateFromArbiter(); this.logicallyActiveHandles.add(handle); this.nativeActivatedHandles.add(handle); this.activeOwner = handle; } async release(handle: AudioFocusHandleImpl): Promise { this.assertLive(handle); await this.releaseHandle(handle); } async disposeHandle(handle: AudioFocusHandleImpl): Promise { if (this.disposed || !this.handles.has(handle)) { await handle.disposeFromArbiter(); this.nativeActivatedHandles.delete(handle); return; } await this.releaseHandle(handle); handle.markDisposedFromArbiter(); this.handles.delete(handle); if (!handle.isNativeActive) { this.nativeActivatedHandles.delete(handle); } } private async releaseHandle(handle: AudioFocusHandleImpl): Promise { this.logicallyActiveHandles.delete(handle); // Deactivate the OS session only when nobody else could be relying on // shared output: exclusive policy never shares, and the last logical // owner of a mixing group must not leave its session dangling. const shouldDeactivateNative = this.policy === 'exclusive' || this.logicallyActiveHandles.size === 0; await handle.releaseFromArbiter({ deactivateNative: shouldDeactivateNative, }); if (!handle.isNativeActive) { this.nativeActivatedHandles.delete(handle); } // Fall back to the most recently activated remaining owner so // getActiveOwnerId() keeps naming whoever is playing now. if (this.activeOwner === handle) { this.activeOwner = getLast(this.logicallyActiveHandles); } if (this.policy === 'mixWithOthers' && this.logicallyActiveHandles.size === 0) { await this.deactivateInactiveNativeHandles(); } } private async deactivateInactiveNativeHandles(): Promise { const inactiveNativeHandles = Array.from(this.nativeActivatedHandles).filter( (handle) => !this.logicallyActiveHandles.has(handle) ); for (const handle of inactiveNativeHandles) { await handle.deactivateNativeFromArbiter(); this.nativeActivatedHandles.delete(handle); } } private assertLive(handle: AudioFocusHandleImpl): void { if (this.disposed || !this.handles.has(handle)) { throw new AviationError( 'DISPOSED', '[Aviation] Audio focus handle is no longer active.' ); } } } function getLast(set: Set): T | undefined { let last: T | undefined; for (const item of set) last = item; return last; } class AudioFocusHandleImpl implements AudioFocusHandle { private active = false; private nativeActive = false; private disposed = false; constructor( readonly owner: AudioFocusOwner, private readonly arbiter: DefaultAudioFocusArbiter ) {} get isActive(): boolean { return this.active; } get isNativeActive(): boolean { return this.nativeActive; } request(): Promise { if (this.disposed) { throw new AviationError( 'DISPOSED', `[Aviation:${this.owner.playerId}] Audio focus handle is disposed.` ); } return this.arbiter.request(this); } release(): Promise { if (this.disposed) return Promise.resolve(); return this.arbiter.release(this); } async dispose(): Promise { if (this.disposed && !this.nativeActive) return; await this.arbiter.disposeHandle(this); } async disposeFromArbiter(): Promise { if (this.disposed && !this.nativeActive) return; await this.releaseFromArbiter({ deactivateNative: true }); this.disposed = true; } async activateFromArbiter(): Promise { if (this.active) return; if (!this.nativeActive) { await this.owner.session.activate(); this.nativeActive = true; } this.active = true; this.owner.onActiveChanged?.(true); } async releaseFromArbiter(options: { deactivateNative: boolean; }): Promise { const wasActive = this.active; if (!wasActive && !(options.deactivateNative && this.nativeActive)) return; if (options.deactivateNative && this.nativeActive) { await this.owner.session.deactivate(); this.nativeActive = false; } if (wasActive) { this.active = false; this.owner.onActiveChanged?.(false); } } async deactivateNativeFromArbiter(): Promise { if (!this.nativeActive) return; await this.owner.session.deactivate(); this.nativeActive = false; } markDisposedFromArbiter(): void { this.disposed = true; } }