/** * 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; private liveRefresh: { generation: number; playWhenReady: boolean } | null = null; private liveRefreshGeneration = 0; constructor(private callbacks: EngineCallbacks) {} load( item: ResolvedMediaItem, opts: { autoplay: boolean; position?: number } ): void { this.liveRefresh = null; 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 { if (this.liveRefresh) { this.liveRefresh.playWhenReady = true; return; } this.playElement(); } private playElement(): 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 { if (this.liveRefresh) this.liveRefresh.playWhenReady = false; this.element?.pause(); } seekToLiveEdge(playWhenReady: boolean): void { const el = this.element; if (el == null || !el.getAttribute('src')) return; if (this.seekElementToLiveEdge(el)) { if (playWhenReady) this.playElement(); return; } if (this.liveRefresh) { this.liveRefresh.playWhenReady = playWhenReady; return; } this.liveRefresh = { generation: ++this.liveRefreshGeneration, playWhenReady, }; const generation = this.liveRefresh.generation; const finish = () => this.finishLiveRefresh(el, generation); el.addEventListener('loadedmetadata', finish, { once: true }); el.addEventListener('canplay', finish, { once: true }); el.addEventListener('error', finish, { once: true }); el.load(); } 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 { this.liveRefresh = null; 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; } private seekElementToLiveEdge(el: HTMLAudioElement): boolean { try { const index = el.seekable.length - 1; if (index < 0) return false; const start = el.seekable.start(index); const end = el.seekable.end(index); if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) { return false; } el.currentTime = Math.max(start, end - 0.5); return true; } catch { return false; } } private finishLiveRefresh(el: HTMLAudioElement, generation: number): void { const refresh = this.liveRefresh; if (refresh == null || refresh.generation !== generation) return; this.seekElementToLiveEdge(el); if (this.liveRefresh?.generation !== generation) return; const playWhenReady = this.liveRefresh.playWhenReady; this.liveRefresh = null; if (playWhenReady) this.playElement(); else el.pause(); } }