import { AudioSession } from './imperative/AudioSession'; import { PlaybackEngine } from './imperative/PlaybackEngine'; import { toMediaItem, type MediaSource } from './imperative/MediaItem'; import { createAviationStore, type AviationCacheEvent, type AviationStore, } from './store'; import type { AudioSession as AudioSessionHybrid } from './specs/AudioSession.nitro'; import type { PlaybackEngine as PlaybackEngineHybrid } from './specs/PlaybackEngine.nitro'; import type { MediaItem as MediaItemHybrid } from './specs/MediaItem.nitro'; import type { AudioSessionConfig, RemoteCommand, PlaybackMetricEvent, PreloadConfig, BufferConfig, MediaItemConfig, DomainEvent, DomainEventType, AviationInterruptionMode, BecomingNoisyBehavior, } from './specs/types.nitro'; import type { AdsController } from './specs/AdsController.nitro'; import type { MediaPlugin, PluginContext, PluginStatus, } from './plugins/MediaPlugin'; import { createPluginRegistry, type PluginRegistry, } from './plugins/pluginRegistry'; import { createRemoteCommandHub, type RemoteCommandHandler, } from './modules/remoteCommands'; import { createOutputWiring } from './modules/outputWiring'; import { createOutputPresence } from './modules/outputPresence'; import type { PlaybackOutputHandle } from './ports/PlaybackOutput'; import { createRouteWiring } from './modules/routeWiring'; import { createAdsWiring } from './modules/adsWiring'; import { createAudioFocusArbiter, type AudioFocusArbiter, type AudioFocusHandle, type AudioFocusPolicy, } from './services/audioFocus'; import { createAviationLogger, devWarn, type AviationLogger, type LogLevel, } from './logger'; import { AviationError } from './errors'; export type { RemoteCommandHandler }; export type { AudioFocusPolicy }; /** Options for {@link createAviation}. */ export interface CreateAviationOptions { audioFocusPolicy?: AudioFocusPolicy; logLevel?: LogLevel; logger?: AviationLogger; } /** * Instance-scoped services shared by all players of one Aviation instance: * the audio-focus arbiter and logger, plus a `dispose()` that tears them down. */ interface AviationServices { readonly audioFocus: AudioFocusArbiter; readonly log: AviationLogger; dispose(): Promise; } export interface CreatePlayerOptions { id?: string; plugins?: MediaPlugin[]; audio?: AudioSessionConfig; deferActivation?: boolean; enabledCommands?: RemoteCommand[]; compactCommands?: RemoteCommand[]; skipIntervalMs?: number; autoDeactivateOnQueueEnd?: boolean; /** * Interruption policy: 'resume' (default) resumes after an interruption * when the OS allows; 'pause' never auto-resumes; 'ignore' leaves the * transport untouched. Changeable at runtime via player.setInterruptionMode. */ interruptionMode?: AviationInterruptionMode; /** * Behavior when the OS broadcasts "headphones unplugged". Default 'pause'. * Applied to the decoder at build time; iOS implements it via route * observation and honors interruptionMode: 'ignore' over it. */ becomingNoisyBehavior?: BecomingNoisyBehavior; queue?: SetQueueOptions; } export interface SetQueueOptions { items: Array; startIndex?: number; autoPlay?: boolean; } export type PlayerLifecycleState = | 'idle' | 'settingUp' | 'ready' | 'tearingDown' | 'failed' | 'disposed'; export interface Aviation { createPlayer(options?: CreatePlayerOptions): Promise; getPlayer(id: string): AviationPlayer | undefined; disposePlayer(id: string): Promise; dispose(): Promise; } /** * A single player instance: one engine, one store, one audio-focus claim. * * Lifecycle tolerance: once a player is disposed (or its setup failed), * transport, queue, configuration, cache, and preload calls degrade to a * dev-only warning plus a no-op instead of throwing. The typical caller is * an async UI callback racing unmount/dispose; forcing every call site into * try/catch turns one lifecycle fact into N error paths, so late commands * are dropped exactly like stale native callbacks. Raw native access via * `engine` / `session` still throws — that is an explicit escape hatch for * plugins and view bindings, and callers there need the real object. */ export interface AviationPlayer { readonly id: string; readonly store: AviationStore; readonly state: PlayerLifecycleState; readonly engine: PlaybackEngineHybrid; readonly session: AudioSessionHybrid; onReady(): Promise; tryGetEngine(): PlaybackEngineHybrid | undefined; /** Whether a plugin with the given name is registered (setup may still be running). */ hasPlugin(name: string): boolean; getPluginStatus(name: string): PluginStatus; setQueue(options: SetQueueOptions): Promise; /** Load a media item without playing it. */ load(source: MediaSource): Promise; /** Load a media item and begin playback. */ loadAndPlay(source: MediaSource): Promise; /** * Position the queue at `index`, playing immediately only when `autoPlay` * is true (default). Resolves when the item has loaded. */ skipToIndex(index: number, autoPlay?: boolean): Promise; activateSession(): Promise; deactivateSession(): Promise; dispose(): Promise; overrideRemoteCommand( command: RemoteCommand, handler: RemoteCommandHandler ): void; clearRemoteCommandOverride(command: RemoteCommand): void; onRemoteCommand( command: RemoteCommand, callback: RemoteCommandHandler ): () => void; clearCache(): Promise; getCacheSize(): number; isCached(uri: string): boolean; setCacheEnabled(enabled: boolean): void; onCacheEvent(callback: (event: AviationCacheEvent) => void): () => void; onPlaybackMetric(callback: (event: PlaybackMetricEvent) => void): () => void; /** * Set the interruption policy. Default 'resume'. A switch takes effect for * the next interruption; in-flight interruptions are not re-run. */ setInterruptionMode(mode: AviationInterruptionMode): void; /** * Subscribe to interruptions (began and ended) with their policy payload. * Delivery is lossless. Returns an unsubscribe function. */ onInterruption(callback: (event: DomainEvent) => void): () => void; /** Last interruption event, for sync reads. */ readonly lastInterruption: DomainEvent | undefined; /** * Set what happens on "headphones unplugged": 'pause' (default) pauses, * 'ignore' keeps playing. */ setBecomingNoisyBehavior(behavior: BecomingNoisyBehavior): void; /** * Subscribe to coordinator domain events. Without `type`, receives every * event — including plugin-raised ad events; with `type`, only that event. * Delivery is lossless: every emission invokes the handler exactly once. * Returns an unsubscribe function. */ onDomainEvent( type: DomainEventType | undefined, callback: (event: DomainEvent) => void ): () => void; /** * Fires when the current item finishes playing naturally (not on stop() * or skip). Returns an unsubscribe function. */ onPlaybackEnded(callback: () => void): () => void; preload(items: MediaItemHybrid[]): void; setPreloadConfig(config: PreloadConfig): void; setPreloadEnabled(enabled: boolean): void; setBufferConfig(config: BufferConfig): void; setAdsController(controller: AdsController): void; clearAdsController(): void; getAdsController(): AdsController | undefined; /** * Registers an alternate playback output — a Cast receiver, say — that can * take playback over from the local engine and hand it back. Core knows * nothing about what the output is; it installs itself on the engine * natively, so commands are redirected there and its position and state * come back through the coordinator like the local decoder's. */ registerOutput(name: string): PlaybackOutputHandle; /** * Transport for whichever output is actually playing. These always go to * the engine; while an output owns playback the engine redirects its * decoder commands natively, so app buttons, notification and lock screen * all take one path. */ play(): Promise; pause(): Promise; stop(): Promise; seekTo(positionMs: number): Promise; skipToNext(): Promise; skipToPrevious(): Promise; } type ReadyWaiter = { resolve: () => void; reject: (error: Error) => void; }; function asError(error: unknown, fallback: string): Error { if (error instanceof Error) return error; return new Error(`${fallback} ${String(error)}`); } class AviationPlayerImpl implements AviationPlayer { readonly store: AviationStore; private sessionRef: AudioSessionHybrid | null = null; private engineRef: PlaybackEngineHybrid | null = null; private audioFocusHandle: AudioFocusHandle | null = null; private lifecycleState: PlayerLifecycleState = 'idle'; private setupPromise: Promise | null = null; private teardownPromise: Promise | null = null; private readyWaiters: ReadyWaiter[] = []; private lastSetupError: Error | null = null; private autoDeactivateOnQueueEnd = true; private plugins: PluginRegistry; private nativeCallbackGeneration = 0; private readonly remoteCommands: ReturnType; private readonly presence: ReturnType; private readonly routes: ReturnType; private readonly outputs: ReturnType; private readonly ads: ReturnType; constructor( readonly id: string, private readonly services: AviationServices, private readonly options: CreatePlayerOptions, private readonly onDisposed?: (player: AviationPlayerImpl) => void ) { this.store = createAviationStore(services.log); this.plugins = createPluginRegistry(services.log); this.remoteCommands = createRemoteCommandHub(services.log); this.presence = createOutputPresence(this.store); this.routes = createRouteWiring(this.presence); this.outputs = createOutputWiring(this.store, this.presence, services.log); this.ads = createAdsWiring(this.store, services.log); } get state(): PlayerLifecycleState { return this.lifecycleState; } get engine(): PlaybackEngineHybrid { return this.getEngineOrThrow(); } get session(): AudioSessionHybrid { if (!this.sessionRef) { throw new AviationError( 'INVALID_STATE', `[Aviation:${this.id}] Player has no audio session.` ); } return this.sessionRef; } private get log() { return this.services.log; } async setup(): Promise { if (this.lifecycleState === 'ready') return; if (this.setupPromise) return this.setupPromise; if (this.lifecycleState !== 'idle' && this.lifecycleState !== 'failed') { throw new AviationError( 'INVALID_STATE', `[Aviation:${this.id}] setup() is not valid from ${this.lifecycleState}.` ); } this.lifecycleState = 'settingUp'; this.lastSetupError = null; this.setupPromise = this.runSetup(); try { await this.setupPromise; } finally { this.setupPromise = null; } } private async runSetup(): Promise { const options = this.options; const audioConfig = options.audio ?? { category: 'playback' as const }; const enabledCommands = options.enabledCommands ?? [ 'play', 'pause', 'nextTrack', 'previousTrack', 'skipForward', 'skipBackward', 'seekTo', 'togglePlayPause', ]; const skipIntervalMs = options.skipIntervalMs ?? 15000; const deferActivation = options.deferActivation ?? true; this.autoDeactivateOnQueueEnd = options.autoDeactivateOnQueueEnd ?? true; try { this.sessionRef = AudioSession.create(audioConfig); this.engineRef = PlaybackEngine.create(); const session = this.sessionRef; const engine = this.engineRef; const generation = this.nextNativeCallbackGeneration(); // Activation truth lives on the handle (handle.isActive) — no JS // shadow copy that can drift from it. this.audioFocusHandle = this.services.audioFocus.createHandle({ playerId: this.id, session, }); if (!deferActivation) { await this.activateSession(); } engine.setAudioSession(session); engine.setEnabledCommands(enabledCommands); if (options.compactCommands) { engine.setCompactCommands(options.compactCommands); } engine.setSkipIntervalMs(skipIntervalMs); if (options.interruptionMode) { engine.setInterruptionMode(options.interruptionMode); } // Always sent, not just when overridden: the documented 'pause' default // must come from one place (here), not from per-platform native // defaults that can drift from it. engine.setBecomingNoisyBehavior(options.becomingNoisyBehavior ?? 'pause'); this.wireNativeCallbacks(engine, generation); this.remoteCommands.wire(engine, () => this.isCurrentNativeCallback(engine, generation) ); this.remoteCommands.applyPendingOverrides(engine); this.routes.wireAirPlayDetection(session, () => this.isCurrentNativeCallback(engine, generation) ); session.onRouteChange((event) => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.recordRouteChange(event); }); // Plugins register before their own setup() runs, so a plugin observes // itself and every earlier sibling via hasPlugin(). for (const plugin of options.plugins ?? []) { await this.plugins.install(plugin, this.createPluginContext(engine)); } if (options.queue) { await this.applyQueue(options.queue); } this.lifecycleState = 'ready'; this.store.setReady(); this.resolveReadyWaiters(); this.log.info('setup', 'Aviation player initialized', { playerId: this.id, plugins: this.plugins.installed.map((p) => p.name), }); } catch (e) { await this.cleanupFailedSetup(e); throw e; } } private async cleanupFailedSetup(setupError: unknown): Promise { this.nextNativeCallbackGeneration(); await this.plugins.teardownAll(() => this.createPluginContext(this.engineRef) ); this.plugins.reset(); if (this.engineRef) { try { await this.engineRef.stop(); } catch (stopError) { this.log.warn('setup', 'engine stop after setup failure failed', { error: stopError, }); } try { this.engineRef.release(); } catch (releaseError) { this.log.warn('setup', 'engine release after setup failure failed', { error: releaseError, }); } } await this.releaseAudioFocus( 'setup', 'session deactivate after setup failure failed' ); this.lifecycleState = 'failed'; this.lastSetupError = asError( setupError, `[Aviation:${this.id}] setup failed.` ); this.engineRef = null; this.sessionRef = null; this.audioFocusHandle = null; this.autoDeactivateOnQueueEnd = true; this.ads.releaseController(); this.outputs.resetState(); this.ads.resetController(); this.store.resetSnapshotsForSetupFailure(); this.rejectReadyWaiters(this.lastSetupError); } async dispose(): Promise { if (this.lifecycleState === 'disposed') return; if (this.teardownPromise) return this.teardownPromise; if (this.setupPromise) { await this.setupPromise.catch(() => {}); } this.lifecycleState = 'tearingDown'; this.teardownPromise = this.runDispose(); try { await this.teardownPromise; } finally { this.teardownPromise = null; this.onDisposed?.(this); } } private async runDispose(): Promise { this.nextNativeCallbackGeneration(); const pluginErrors = await this.plugins.teardownAll(() => this.createPluginContext(this.engineRef) ); this.plugins.reset(); this.ads.releaseController(); if (this.engineRef) { try { await this.engineRef.stop(); } catch (e) { this.log.warn('teardown', 'engine stop failed', { error: e }); } try { this.engineRef.release(); } catch (e) { this.log.warn('teardown', 'engine release failed', { error: e }); } } await this.releaseAudioFocus('teardown', 'session deactivate failed'); this.lifecycleState = 'disposed'; this.lastSetupError = null; this.engineRef = null; this.sessionRef = null; this.audioFocusHandle = null; this.autoDeactivateOnQueueEnd = true; this.remoteCommands.reset(); this.outputs.resetState(); this.ads.resetController(); this.store.reset(); this.rejectReadyWaiters( new Error(`[Aviation:${this.id}] Player disposed before it became ready.`) ); if (pluginErrors.length > 0) { const causes = pluginErrors.map(({ error }) => error); const msg = `Plugin teardown failed: ${pluginErrors .map(({ name }) => name) .join(', ')}`; if (typeof AggregateError !== 'undefined') { throw new AggregateError(causes, msg); } const err = new Error(msg) as Error & { errors: unknown[] }; err.errors = causes; throw err; } } onReady(): Promise { if (this.lifecycleState === 'ready') return Promise.resolve(); if (this.lifecycleState === 'failed') { return Promise.reject( this.lastSetupError ?? new Error(`[Aviation:${this.id}] setup failed.`) ); } if (this.lifecycleState === 'disposed') { return Promise.reject(new Error(`[Aviation:${this.id}] disposed.`)); } return new Promise((resolve, reject) => { this.readyWaiters.push({ resolve, reject }); }); } tryGetEngine(): PlaybackEngineHybrid | undefined { return this.engineRef ?? undefined; } hasPlugin(name: string): boolean { return this.plugins.installed.some((plugin) => plugin.name === name); } getPluginStatus(name: string): PluginStatus { return this.plugins.status(name); } async setQueue(options: SetQueueOptions): Promise { await this.applyQueue(options); } async activateSession(): Promise { const handle = this.liveOrWarn('activateSession', this.audioFocusHandle); if (!handle) return; await handle.request(); } async deactivateSession(): Promise { const handle = this.liveOrWarn('deactivateSession', this.audioFocusHandle); if (!handle) return; await handle.release(); } private async releaseAudioFocus(tag: string, message: string): Promise { const handle = this.audioFocusHandle; if (!handle) { if (!this.sessionRef) return; try { await this.sessionRef.deactivate(); } catch (e) { this.log.warn(tag, message, { error: e }); } return; } try { await handle.dispose(); } catch (e) { this.log.warn(tag, message, { error: e }); } } overrideRemoteCommand( command: RemoteCommand, handler: RemoteCommandHandler ): void { this.remoteCommands.override(command, handler, this.engineRef ?? undefined); } clearRemoteCommandOverride(command: RemoteCommand): void { this.remoteCommands.clearOverride(command, this.engineRef ?? undefined); } onRemoteCommand( command: RemoteCommand, callback: RemoteCommandHandler ): () => void { return this.remoteCommands.observe(command, callback); } /** * Guard for public commands on resources that die with the player. * Returns the live resource, or null (with a dev-only warning) once the * player is disposed or its setup failed. Late commands are lifecycle * races, not usage errors, so they drop instead of throwing. */ private liveOrWarn(tag: string, resource: T | null): T | null { if (resource !== null) return resource; devWarn( 'player', `${tag}() ignored — player '${this.id}' is ${this.lifecycleState}.` ); return null; } async clearCache(): Promise { const engine = this.liveOrWarn('clearCache', this.engineRef); if (!engine) return; this.log.debug('cache', 'clearing cache'); await engine.clearCache(); } getCacheSize(): number { return this.liveOrWarn('getCacheSize', this.engineRef)?.getCacheSize() ?? 0; } isCached(uri: string): boolean { return this.liveOrWarn('isCached', this.engineRef)?.isCached(uri) ?? false; } setCacheEnabled(enabled: boolean): void { const engine = this.liveOrWarn('setCacheEnabled', this.engineRef); engine?.setCacheEnabled(enabled); if (engine) this.log.debug('cache', 'cache enabled changed', { enabled }); } onCacheEvent(callback: (event: AviationCacheEvent) => void): () => void { return this.store.subscribeCacheEvent(callback); } onPlaybackMetric(callback: (event: PlaybackMetricEvent) => void): () => void { return this.store.subscribePlaybackMetric(callback); } setInterruptionMode(mode: AviationInterruptionMode): void { const engine = this.liveOrWarn('setInterruptionMode', this.engineRef); engine?.setInterruptionMode(mode); if (engine) this.log.debug('interruption', 'interruption mode changed', { mode }); } onInterruption(callback: (event: DomainEvent) => void): () => void { return this.store.subscribeInterruption(callback); } get lastInterruption(): DomainEvent | undefined { return this.store.lastInterruption; } setBecomingNoisyBehavior(behavior: BecomingNoisyBehavior): void { const engine = this.liveOrWarn('setBecomingNoisyBehavior', this.engineRef); engine?.setBecomingNoisyBehavior(behavior); if (engine) { this.log.debug('interruption', 'becoming-noisy behavior changed', { behavior, }); } } onDomainEvent( type: DomainEventType | undefined, callback: (event: DomainEvent) => void ): () => void { if (type === undefined) { return this.store.subscribeDomainEvent(callback); } const filtered = (event: DomainEvent) => { if (event.type === type) callback(event); }; return this.store.subscribeDomainEvent(filtered); } onPlaybackEnded(callback: () => void): () => void { return this.store.subscribePlaybackEnded(callback); } preload(items: MediaItemHybrid[]): void { const engine = this.liveOrWarn('preload', this.engineRef); if (!engine) return; this.log.debug('preload', 'preloading items', { count: items.length }); engine.preload(items); } setPreloadConfig(config: PreloadConfig): void { this.liveOrWarn('setPreloadConfig', this.engineRef)?.setPreloadConfig(config); } setPreloadEnabled(enabled: boolean): void { const engine = this.liveOrWarn('setPreloadEnabled', this.engineRef); engine?.setPreloadEnabled(enabled); if (engine) this.log.debug('preload', 'preload enabled changed', { enabled }); } setBufferConfig(config: BufferConfig): void { this.liveOrWarn('setBufferConfig', this.engineRef)?.setBufferConfig(config); } setAdsController(controller: AdsController): void { this.ads.setController(controller); } clearAdsController(): void { this.ads.clearController(); } getAdsController(): AdsController | undefined { return this.ads.getController(); } registerOutput(name: string): PlaybackOutputHandle { return this.outputs.registerOutput(name, this.engineRef); } async load(source: MediaSource): Promise { const engine = this.liveOrWarn('load', this.engineRef); if (!engine) return; await engine.load(toMediaItem(source)); } async loadAndPlay(source: MediaSource): Promise { const engine = this.liveOrWarn('loadAndPlay', this.engineRef); if (!engine) return; await engine.loadAndPlay(toMediaItem(source)); } async skipToIndex(index: number, autoPlay?: boolean): Promise { const engine = this.liveOrWarn('skipToIndex', this.engineRef); if (!engine) return; // The native primitive positions and loads the item, playing only when // asked; either way the promise settles on the loading -> ready transition. await engine.skipToIndex(index, autoPlay ?? true); } async play(): Promise { const engine = this.liveOrWarn('play', this.engineRef); if (engine) await engine.play(); } async pause(): Promise { const engine = this.liveOrWarn('pause', this.engineRef); if (engine) await engine.pause(); } async stop(): Promise { const engine = this.liveOrWarn('stop', this.engineRef); if (engine) await engine.stop(); } async seekTo(positionMs: number): Promise { const engine = this.liveOrWarn('seekTo', this.engineRef); if (engine) await engine.seekTo(positionMs); } async skipToNext(): Promise { const engine = this.liveOrWarn('skipToNext', this.engineRef); if (engine) await engine.skipToNext(); } async skipToPrevious(): Promise { const engine = this.liveOrWarn('skipToPrevious', this.engineRef); if (engine) await engine.skipToPrevious(); } private async applyQueue(options: SetQueueOptions): Promise { const engine = this.liveOrWarn('setQueue', this.engineRef); if (!engine) return; const hybrids = options.items.map(toMediaItem); engine.setQueue(hybrids); const autoPlay = options.autoPlay ?? false; const index = options.startIndex ?? (autoPlay ? 0 : undefined); if (index === undefined) return; if (index < 0 || index >= hybrids.length) { this.log.warn('queue', 'startIndex out of range, ignoring', { index, length: hybrids.length, }); return; } // The native primitive positions and loads the item, playing only when // asked; either way the promise settles on the loading -> ready transition. await engine.skipToIndex(index, autoPlay); } private wireNativeCallbacks( engine: PlaybackEngineHybrid, generation: number ): void { engine.clearCallbacks(); engine.onStateChange((state) => { if (!this.isCurrentNativeCallback(engine, generation)) return; const focusHandle = this.audioFocusHandle; if ( focusHandle && !focusHandle.isActive && (state === 'loading' || state === 'playing' || state === 'buffering') ) { focusHandle.request().catch((e) => { if ( !this.isCurrentNativeCallback(engine, generation) || this.audioFocusHandle !== focusHandle ) { return; } this.log.warn('engine', 'lazy audio session activation failed', { error: e, }); }); } this.store.setPlaybackState(state); }); engine.onMediaPositionChange((position) => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.setMediaPosition(position); }); engine.onCurrentItemChange((item, source) => { if (!this.isCurrentNativeCallback(engine, generation)) return; const queueIndex = source === 'queue' ? engine.queueIndex : -1; this.store.setCurrentItem(item, source, queueIndex); }); engine.onPlaybackMetric((event) => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.setPlaybackMetric(event); }); engine.onDomainEvent((event) => { if (!this.isCurrentNativeCallback(engine, generation)) return; if (event.type === 'interruption') { this.store.recordInterruption(event); } this.store.dispatchDomainEvent(event); }); engine.onError((message, code) => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.setError(message, code); }); engine.onPlaybackEnded(() => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.notifyPlaybackEnded(); }); engine.onQueueEnd(() => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.notifyQueueEnd(); if ( this.autoDeactivateOnQueueEnd && this.audioFocusHandle?.isActive ) { const handle = this.audioFocusHandle; handle.release().catch((e) => { if ( !this.isCurrentNativeCallback(engine, generation) || this.audioFocusHandle !== handle ) { return; } this.log.warn('engine', 'auto session deactivation failed', { error: e, }); }); } }); engine.onQueueChange(() => { if (!this.isCurrentNativeCallback(engine, generation)) return; this.store.notifyQueueChanged(); }); } private createPluginContext( engine: PlaybackEngineHybrid | null ): PluginContext { return { player: this, engine: engine ?? this.getEngineOrThrow(), store: this.store, }; } private nextNativeCallbackGeneration(): number { this.nativeCallbackGeneration++; return this.nativeCallbackGeneration; } private isCurrentNativeCallback( engine: PlaybackEngineHybrid, generation: number ): boolean { return ( this.engineRef === engine && this.nativeCallbackGeneration === generation && (this.lifecycleState === 'settingUp' || this.lifecycleState === 'ready') ); } private getEngineOrThrow(): PlaybackEngineHybrid { if (!this.engineRef) { throw new AviationError( 'NOT_READY', `[Aviation:${this.id}] Player is not ready.` ); } return this.engineRef; } private resolveReadyWaiters(): void { for (const { resolve } of this.readyWaiters) resolve(); this.readyWaiters = []; } private rejectReadyWaiters(error: Error): void { for (const { reject } of this.readyWaiters) reject(error); this.readyWaiters = []; } } class AviationImpl implements Aviation { private readonly players = new Map(); private nextPlayerId = 1; constructor(private readonly services: AviationServices) {} async createPlayer( options: CreatePlayerOptions = {} ): Promise { const id = options.id ?? `player-${this.nextPlayerId++}`; if (this.players.has(id)) { throw new AviationError( 'PLAYER_EXISTS', `[Aviation] Player '${id}' already exists.` ); } const player = new AviationPlayerImpl( id, this.services, { ...options, id, }, (disposedPlayer) => { if (this.players.get(disposedPlayer.id) === disposedPlayer) { this.players.delete(disposedPlayer.id); } } ); this.players.set(id, player); try { await player.setup(); return player; } catch (e) { this.players.delete(id); throw e; } } getPlayer(id: string): AviationPlayer | undefined { return this.players.get(id); } async disposePlayer(id: string): Promise { const player = this.players.get(id); if (!player) return; this.players.delete(id); await player.dispose(); } async dispose(): Promise { const players = Array.from(this.players.values()); this.players.clear(); await Promise.all(players.map((player) => player.dispose())); await this.services.dispose(); } } export function createAviation(options: CreateAviationOptions = {}): Aviation { const audioFocus = createAudioFocusArbiter({ policy: options.audioFocusPolicy ?? 'exclusive', }); const log = createAviationLogger({ level: options.logLevel ?? 'none', logger: options.logger, }); return new AviationImpl({ audioFocus, log, dispose: () => audioFocus.dispose(), }); }