/** * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ import { headersOf, uriOf, type AudioEngine, type EngineCallbacks, type ResolvedMediaItem, } from './AudioEngine'; /** * Default engine: one detached HTMLAudioElement, zero dependencies. * Plays progressive audio everywhere and HLS natively on Safari. */ export class HtmlAudioEngine implements AudioEngine { private element: HTMLAudioElement | null = null; private pendingStartPosition: number | null = null; constructor(private callbacks: EngineCallbacks) {} load( item: ResolvedMediaItem, opts: { autoplay: boolean; position?: number } ): void { if (headersOf(item.url) != null) { this.callbacks.onError({ code: 'source', message: '@rntp/player (web): custom HTTP headers are not supported by the ' + 'HTML audio engine. Install shaka-player to play header-authenticated media.', }); return; } const el = this.ensureElement(); this.pendingStartPosition = opts.position ?? null; el.src = uriOf(item.url); el.load(); if (opts.autoplay) this.play(); } play(): void { const el = this.element; if (el == null) return; const result = el.play(); // play() returns a promise in browsers; undefined in some test stubs. void result?.catch((error: unknown) => { const isNotAllowed = error instanceof DOMException && error.name === 'NotAllowedError'; this.callbacks.onError({ code: isNotAllowed ? 'play-not-permitted' : 'unknown', message: error instanceof Error || error instanceof DOMException ? error.message : String(error), }); }); } pause(): void { this.element?.pause(); } stop(): void { const el = this.element; if (el == null) return; el.pause(); el.removeAttribute('src'); el.load(); this.callbacks.onStateChange('idle'); } seekTo(seconds: number): void { if (this.element) this.element.currentTime = seconds; } setVolume(volume: number): void { // Note: iOS Safari ignores programmatic volume (hardware-controlled). if (this.element) this.element.volume = volume; } setPlaybackSpeed(rate: number): void { if (this.element) this.element.playbackRate = rate; } getProgress(): { position: number; duration: number; buffered: number } { const el = this.element; if (el == null) return { position: 0, duration: 0, buffered: 0 }; const position = el.currentTime; const duration = Number.isFinite(el.duration) ? el.duration : 0; let buffered = 0; for (let i = 0; i < el.buffered.length; i++) { if (el.buffered.start(i) <= position && position <= el.buffered.end(i)) { buffered = el.buffered.end(i); break; } } return { position, duration, buffered }; } preload(): void {} cancelPreload(): void {} clearCache(): void {} destroy(): void { if (this.element) { this.element.pause(); this.element.removeAttribute('src'); this.element = null; } } private ensureElement(): HTMLAudioElement { if (this.element) return this.element; const el = document.createElement('audio'); el.id = 'rntp-player'; el.preload = 'auto'; // Deliberately NOT setting `crossOrigin`: for plain playback we never read // the media's bytes (no Web Audio / canvas), and forcing a CORS request // would make any source without `Access-Control-Allow-Origin` headers fail // to load — which is most third-party MP3s and radio streams. If a future // feature needs cross-origin reads (e.g. an AnalyserNode visualizer), set // it then, gated on that feature. // Keep pitch constant when playbackRate != 1 (podcast speed-up). (el as HTMLAudioElement & { preservesPitch?: boolean }).preservesPitch = true; el.addEventListener('loadstart', () => this.callbacks.onStateChange('loading') ); el.addEventListener('waiting', () => this.callbacks.onStateChange('buffering') ); el.addEventListener('canplay', () => this.callbacks.onStateChange('ready')); el.addEventListener('playing', () => this.callbacks.onStateChange('ready')); el.addEventListener('ended', () => this.callbacks.onEnded()); el.addEventListener('timeupdate', () => this.callbacks.onTimeUpdate(el.currentTime) ); el.addEventListener('loadedmetadata', () => { if (this.pendingStartPosition != null) { el.currentTime = this.pendingStartPosition; this.pendingStartPosition = null; } }); el.addEventListener('error', () => { const code = el.error?.code; this.callbacks.onError({ code: code === 2 // MEDIA_ERR_NETWORK ? 'network' : code === 3 // MEDIA_ERR_DECODE ? 'renderer' : code === 4 // MEDIA_ERR_SRC_NOT_SUPPORTED ? 'source' : 'unknown', message: el.error?.message || 'Media element error', }); }); this.element = el; return el; } }