/** * 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'; let shakaModulePromise: Promise | null = null; /** * Lazily imports shaka-player (optional peer dependency). Never imported at * module scope — non-shaka users pay zero bundle cost and SSR never sees it. */ function ensureShaka(): Promise { if (!shakaModulePromise) { // @ts-expect-error optional peer dependency shakaModulePromise = import('shaka-player').then((mod: any) => { const shaka = mod.default ?? mod; shaka.polyfill.installAll(); if (!shaka.Player.isBrowserSupported()) { throw new Error('shaka-player reports this browser as unsupported.'); } return shaka; }); shakaModulePromise.catch(() => { shakaModulePromise = null; // allow retry after install }); } return shakaModulePromise; } /** HLS/DASH/live engine backed by shaka-player. Supports custom headers. */ export class ShakaEngine implements AudioEngine { private element: HTMLAudioElement | null = null; private player: any = null; private headers: Record | undefined; private loadToken = 0; constructor(private callbacks: EngineCallbacks) {} load( item: ResolvedMediaItem, opts: { autoplay: boolean; position?: number } ): void { const token = ++this.loadToken; this.callbacks.onStateChange('loading'); void this.loadAsync(item, opts, token); } private async loadAsync( item: ResolvedMediaItem, opts: { autoplay: boolean; position?: number }, token: number ): Promise { let shaka: any; try { shaka = await ensureShaka(); } catch (e) { this.callbacks.onError({ code: 'source', message: '@rntp/player (web): this media requires the shaka-player engine ' + '(HLS/DASH or custom headers). Install the optional peer dependency: ' + `yarn add shaka-player. (${e instanceof Error ? e.message : String(e)})`, }); return; } if (token !== this.loadToken) return; // superseded by a newer load try { const el = this.ensureElement(); if (!this.player) { this.player = new shaka.Player(); await this.player.attach(el); this.player.addEventListener('buffering', (event: any) => { this.callbacks.onStateChange(event.buffering ? 'buffering' : 'ready'); }); this.player.addEventListener('error', (event: any) => { this.emitShakaError(event.detail); }); } this.headers = headersOf(item.url); this.player.getNetworkingEngine().clearAllRequestFilters?.(); this.player .getNetworkingEngine() .registerRequestFilter((_type: unknown, request: any) => { if (this.headers) Object.assign(request.headers, this.headers); }); await this.player.load(uriOf(item.url), opts.position ?? null); if (token !== this.loadToken) return; this.callbacks.onStateChange('ready'); if (opts.autoplay) this.play(); } catch (e: any) { if (token !== this.loadToken) return; this.emitShakaError(e); } } private emitShakaError(error: any): void { // shaka.util.Error categories: 1 = NETWORK, 2 = TEXT, 3 = MEDIA, 4 = MANIFEST... const category = error?.category; this.callbacks.onError({ code: category === 1 ? 'network' : category === 3 ? 'renderer' : 'source', message: `shaka error ${error?.code ?? ''}`.trim(), }); } play(): void { const el = this.element; if (el == null) return; void el.play()?.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.message : String(error), }); }); } pause(): void { this.element?.pause(); } stop(): void { // Pause the element synchronously — shaka's unload() is async and would // otherwise let the stream keep playing for a frame or two after stop(). this.element?.pause(); void this.player?.unload(); this.callbacks.onStateChange('idle'); } seekTo(seconds: number): void { if (this.element) this.element.currentTime = seconds; } setVolume(volume: number): void { 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 { void this.player?.destroy(); this.player = null; this.element = null; } private ensureElement(): HTMLAudioElement { if (this.element) return this.element; const el = document.createElement('audio'); el.id = 'rntp-player-shaka'; el.addEventListener('ended', () => this.callbacks.onEnded()); el.addEventListener('timeupdate', () => this.callbacks.onTimeUpdate(el.currentTime) ); el.addEventListener('playing', () => this.callbacks.onStateChange('ready')); el.addEventListener('waiting', () => this.callbacks.onStateChange('buffering') ); this.element = el; return el; } }