/** * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ import { Event, PlaybackState } from '../events'; import { RepeatMode } from '../interfaces/RepeatMode'; import type { RemoteControlConfig } from '../interfaces/PlayerConfig'; import { EventBus } from './EventBus'; import { MediaSessionController } from './MediaSessionController'; import { QueueManager } from './QueueManager'; import { SleepTimerController } from './SleepTimerController'; import { IS_DEV } from './env'; import { uriOf, type AudioEngine, type EngineCallbacks, type EngineProvider, type EngineState, type ResolvedMediaItem, } from './engines/AudioEngine'; import { DefaultEngineProvider } from './engines/resolveEngine'; interface WebTrackPlayerOptions { /** Injected by tests; defaults to {@link DefaultEngineProvider}. */ engineProvider?: EngineProvider; /** Injected by tests; defaults to navigator.mediaSession. */ mediaSession?: ConstructorParameters[1]; } /** * Pure-TS implementation of the native module Spec for the web platform. * Receives already-resolved payloads from src/audio.ts. */ export class WebTrackPlayer { readonly eventBus = new EventBus(); private queue = new QueueManager(); private provider: EngineProvider | null = null; private injectedProvider: EngineProvider | null = null; private engine: AudioEngine | null = null; private isSetup = false; private playWhenReady = false; private engineState: EngineState = 'idle'; private repeatMode: RepeatMode = RepeatMode.Off; private volume = 1; private speed = 1; private lastEmittedState: PlaybackState = PlaybackState.Idle; private lastEmittedPlaying = false; private warned = new Set(); private lastProgressEmitMs = 0; private mediaSession: MediaSessionController; private firstPlayingSeen = false; private sleepTimer = new SleepTimerController({ pause: () => this.pause(), getVolume: () => this.volume, setEngineVolume: (v) => this.engine?.setVolume(v), onTriggered: (type) => this.eventBus.emit(Event.SleepTimerTriggered, { type }), }); private readonly engineCallbacks: EngineCallbacks = { onStateChange: (state) => this.handleEngineState(state), onEnded: () => this.handleEnded(), onError: (error) => this.handleEngineError(error), onTimeUpdate: (position) => this.handleTimeUpdate(position), }; constructor(options: WebTrackPlayerOptions = {}) { this.injectedProvider = options.engineProvider ?? null; this.mediaSession = new MediaSessionController( { play: () => this.play(), pause: () => this.pause(), stop: () => this.stop(), next: () => this.skipToNext(), previous: () => this.skipToPrevious(), seekTo: (position) => this.seekTo(position), seekBy: (offset) => this.seekBy(offset), emit: (event, payload) => this.eventBus.emit(event, payload), }, options.mediaSession ); } // ── Setup / teardown ──────────────────────────────── setupPlayer(_config: Record): void { if (this.injectedProvider == null && typeof window === 'undefined') { throw new Error( '@rntp/player: setupPlayer() was called outside a browser (server-side ' + 'rendering?). Call it on the client, e.g. inside a useEffect.' ); } this.provider = this.injectedProvider ?? this.createDefaultProvider(); this.isSetup = true; } private createDefaultProvider(): EngineProvider { return new DefaultEngineProvider(); } destroy(): void { this.sleepTimer.destroy(); this.mediaSession.destroy(); this.firstPlayingSeen = false; this.provider?.destroy(); this.provider = null; this.engine = null; this.queue.clear(); this.playWhenReady = false; this.engineState = 'idle'; this.lastEmittedState = PlaybackState.Idle; this.lastEmittedPlaying = false; this.eventBus.removeAllListeners(); this.isSetup = false; } // ── Playback ──────────────────────────────────────── play(): void { this.requireSetup('play'); this.playWhenReady = true; if (this.engine == null && this.queue.current != null) { this.loadCurrent({ autoplay: true }); } else { this.engine?.play(); } this.emitDerivedState(); } pause(): void { this.requireSetup('pause'); this.playWhenReady = false; this.engine?.pause(); this.emitDerivedState(); } stop(): void { this.requireSetup('stop'); this.playWhenReady = false; this.engine?.stop(); this.emitDerivedState(); } seekTo(position: number): void { this.requireSetup('seekTo'); this.engine?.seekTo(Math.max(0, position)); } seekBy(offset: number): void { this.requireSetup('seekBy'); const { position } = this.getProgress(); this.seekTo(position + offset); } skipToNext(): void { this.requireSetup('skipToNext'); if (this.queue.next(this.repeatMode === RepeatMode.All)) { this.loadCurrent({ autoplay: this.playWhenReady }); this.emitTransition(); } } skipToPrevious(): void { this.requireSetup('skipToPrevious'); if (this.getProgress().position > 3) { this.seekTo(0); return; } if (this.queue.previous(this.repeatMode === RepeatMode.All)) { this.loadCurrent({ autoplay: this.playWhenReady }); this.emitTransition(); } } skipToIndex(index: number): void { this.requireSetup('skipToIndex'); this.queue.jump(index); this.loadCurrent({ autoplay: this.playWhenReady }); this.emitTransition(); } retry(): void { this.requireSetup('retry'); if (this.lastEmittedState !== PlaybackState.Error) return; const position = this.getProgress().position; this.loadCurrent({ autoplay: false, position }); } setPlaybackSpeed(speed: number): void { this.requireSetup('setPlaybackSpeed'); this.speed = speed; this.engine?.setPlaybackSpeed(speed); } setVolume(volume: number): void { this.requireSetup('setVolume'); this.volume = Math.min(1, Math.max(0, volume)); this.engine?.setVolume(this.volume); } // ── Queue ─────────────────────────────────────────── setMediaItem(mediaItem: ResolvedMediaItem): void { this.setMediaItems([mediaItem], 0); } setMediaItems(mediaItems: ResolvedMediaItem[], startIndex: number): void { this.requireSetup('setMediaItems'); this.queue.clear(); this.queue.add(mediaItems); this.playWhenReady = false; if (mediaItems.length > 0) { this.queue.jump(Math.min(startIndex, mediaItems.length - 1)); this.loadCurrent({ autoplay: false }); } this.emitQueueChanged(); this.emitTransition(); } addMediaItem(mediaItem: ResolvedMediaItem): void { this.addMediaItems([mediaItem]); } addMediaItems(mediaItems: ResolvedMediaItem[]): void { this.requireSetup('addMediaItems'); const wasEmpty = this.queue.items.length === 0; this.queue.add(mediaItems); this.emitQueueChanged(); if (wasEmpty && this.queue.items.length > 0) { this.queue.jump(0); this.loadCurrent({ autoplay: false }); this.emitTransition(); } } insertMediaItem(index: number, mediaItem: ResolvedMediaItem): void { this.insertMediaItems(index, [mediaItem]); } insertMediaItems(index: number, mediaItems: ResolvedMediaItem[]): void { this.requireSetup('insertMediaItems'); this.queue.insert(index, mediaItems); this.emitQueueChanged(); } removeMediaItem(index: number): void { this.requireSetup('removeMediaItem'); const removedCurrent = this.queue.remove(index); this.emitQueueChanged(); if (removedCurrent) this.afterCurrentRemoved(); } removeMediaItems(fromIndex: number, toIndex: number): void { this.requireSetup('removeMediaItems'); const removedCurrent = this.queue.removeRange(fromIndex, toIndex); this.emitQueueChanged(); if (removedCurrent) this.afterCurrentRemoved(); } clear(): void { this.requireSetup('clear'); this.queue.clear(); this.engine?.stop(); this.playWhenReady = false; this.emitQueueChanged(); this.emitTransition(); this.emitDerivedState(); } replaceMediaItem(index: number, mediaItem: ResolvedMediaItem): void { this.requireSetup('replaceMediaItem'); const isActive = index === this.queue.currentIndex; const sameUrl = isActive && this.queue.current != null && uriOf(this.queue.current.url) === uriOf(mediaItem.url); this.queue.replace(index, mediaItem); this.emitQueueChanged(); if (isActive && !sameUrl) { this.loadCurrent({ autoplay: this.playWhenReady }); this.emitTransition(); } else if (isActive) { this.emitMetadataChanged(); } } moveMediaItem(fromIndex: number, toIndex: number): void { this.requireSetup('moveMediaItem'); this.queue.move(fromIndex, toIndex); this.emitQueueChanged(); } updateMetadata( index: number, metadata: Partial< Pick > ): void { this.requireSetup('updateMetadata'); const existing = this.queue.items[index]; if (existing == null) throw new Error(`updateMetadata index ${index} out of bounds`); this.queue.replace(index, { ...existing, ...metadata }); if (index === this.queue.currentIndex) this.emitMetadataChanged(); } // ── Getters ───────────────────────────────────────── getPlaybackState(): string { return this.mapState(); } isPlaying(): boolean { return this.playWhenReady && this.mapState() === PlaybackState.Ready; } getProgress(): { position: number; duration: number; buffered: number; cached: number; } { const p = this.engine?.getProgress() ?? { position: 0, duration: 0, buffered: 0, }; return { ...p, cached: 0 }; } getPlaybackSpeed(): number { return this.speed; } getVolume(): number { return this.volume; } getActiveMediaItem(): ResolvedMediaItem | null { return this.queue.current; } getActiveMediaItemIndex(): number | null { return this.queue.currentIndex >= 0 ? this.queue.currentIndex : null; } getQueue(): ResolvedMediaItem[] { return [...this.queue.items]; } getRepeatMode(): string { return this.repeatMode; } setRepeatMode(mode: string): void { this.requireSetup('setRepeatMode'); this.repeatMode = mode as RepeatMode; } isShuffleEnabled(): boolean { return this.queue.isShuffleEnabled(); } setShuffleEnabled(enabled: boolean): void { this.requireSetup('setShuffleEnabled'); this.queue.setShuffleEnabled(enabled); } // ── Web no-ops (documented in the parity matrix) ──── setCommands(commands: Record): void { this.requireSetup('setCommands'); this.mediaSession.setCommands(commands as unknown as RemoteControlConfig); } setBrowseTree(_categories: unknown[]): void { this.warnOnce( 'setBrowseTree', 'setBrowseTree is Android Auto / CarPlay only.' ); } updateProgressSyncHeaders(_headers: Record): void { this.warnOnce( 'updateProgressSyncHeaders', 'progressSync is not yet supported on web.' ); } preload(item: ResolvedMediaItem, _duration: number): void { this.engineForItem(item)?.preload(item); } cancelPreload(item: ResolvedMediaItem): void { this.engineForItem(item)?.cancelPreload(item); } clearCache(): void { this.engine?.clearCache(); } // ── Sleep timer ───────────────────────────────────── sleepAfterTime(seconds: number, fadeOutSeconds: number): void { this.requireSetup('sleepAfterTime'); this.sleepTimer.sleepAfterTime(seconds, fadeOutSeconds); } sleepAfterMediaItemAtIndex(index: number): void { this.requireSetup('sleepAfterMediaItemAtIndex'); this.sleepTimer.sleepAfterMediaItemAtIndex(index); } getSleepTimer(): Record | null { return this.sleepTimer.getState(); } cancelSleepTimer(): void { this.sleepTimer.cancel(); } // ── RCTEventEmitter compat (no-ops on web) ────────── addListener(_eventType: string): void {} removeListeners(_count: number): void {} // ── Internals ─────────────────────────────────────── private requireSetup(method: string): void { if (!this.isSetup) { throw new Error(`@rntp/player: ${method}() called before setupPlayer().`); } } private loadCurrent(opts: { autoplay: boolean; position?: number }): void { this.lastProgressEmitMs = 0; const item = this.queue.current; if (item == null || this.provider == null) return; const next = this.provider.engineFor(item, this.engineCallbacks); if (this.engine && this.engine !== next) this.engine.stop(); this.engine = next; this.engine.load(item, opts); this.engine.setVolume(this.volume); this.engine.setPlaybackSpeed(this.speed); } private engineForItem(item: ResolvedMediaItem): AudioEngine | null { return this.provider?.engineFor(item, this.engineCallbacks) ?? null; } private afterCurrentRemoved(): void { if (this.queue.current != null) { this.loadCurrent({ autoplay: this.playWhenReady }); } else { this.engine?.stop(); this.playWhenReady = false; this.emitDerivedState(); } this.emitTransition(); } private handleEngineState(state: EngineState): void { this.engineState = state; this.emitDerivedState(); } private handleEnded(): void { // Runs before any advance/replay so a sleep timer can pause on the right // boundary; if it fired, it cleared playWhenReady and we must not replay. this.sleepTimer.notifyItemEnded(this.queue.currentIndex); if (this.repeatMode === RepeatMode.One) { this.engine?.seekTo(0); if (this.playWhenReady) this.engine?.play(); this.emitDerivedState(); return; } if (this.queue.next(this.repeatMode === RepeatMode.All)) { this.loadCurrent({ autoplay: this.playWhenReady }); this.emitTransition(); return; } this.engineState = 'ended'; this.playWhenReady = false; this.emitDerivedState(); } private handleEngineError(error: { code: string; message: string }): void { if (error.code === 'play-not-permitted') { this.playWhenReady = false; this.eventBus.emit(Event.PlaybackError, error); this.emitDerivedState(); return; } this.engineState = 'error'; this.eventBus.emit(Event.PlaybackError, error); this.emitDerivedState(); } private handleTimeUpdate(position: number): void { const nowMs = Date.now(); if (nowMs - this.lastProgressEmitMs < 1000) return; this.lastProgressEmitMs = nowMs; const current = this.queue.current; if (current == null) return; const { duration } = this.getProgress(); this.eventBus.emit(Event.PlaybackProgressUpdated, { mediaId: current.mediaId ?? uriOf(current.url), position, duration, timestamp: nowMs, }); this.mediaSession.updatePositionState({ duration, position, playbackRate: this.speed, }); } private mapState(): PlaybackState { switch (this.engineState) { case 'idle': return PlaybackState.Idle; case 'loading': case 'buffering': return PlaybackState.Buffering; case 'ready': return PlaybackState.Ready; case 'ended': return PlaybackState.Ended; case 'error': return PlaybackState.Error; } } private emitDerivedState(): void { const state = this.mapState(); if (state !== this.lastEmittedState) { this.lastEmittedState = state; this.eventBus.emit(Event.PlaybackStateChanged, { state }); } const playing = this.isPlaying(); if (playing !== this.lastEmittedPlaying) { this.lastEmittedPlaying = playing; this.eventBus.emit(Event.IsPlayingChanged, { playing }); } this.mediaSession.updatePlaybackState( playing ? 'playing' : this.queue.current != null ? 'paused' : 'none' ); if (playing && !this.firstPlayingSeen) { this.firstPlayingSeen = true; this.mediaSession.reassert(); } } private emitQueueChanged(): void { this.eventBus.emit(Event.QueueChanged, undefined); } private emitTransition(): void { this.eventBus.emit(Event.MediaItemTransition, { item: this.queue.current, index: this.queue.currentIndex >= 0 ? this.queue.currentIndex : -1, }); this.mediaSession.updateMetadata(this.queue.current); } private emitMetadataChanged(): void { const item = this.queue.current; if (item == null) return; this.eventBus.emit(Event.MediaMetadataChanged, { title: item.title, artist: item.artist, albumTitle: item.albumTitle, artworkUrl: item.artworkUrl != null ? uriOf(item.artworkUrl) : undefined, }); this.mediaSession.updateMetadata(item); } private warnOnce(key: string, message: string): void { if (!IS_DEV || this.warned.has(key)) return; this.warned.add(key); console.warn(`@rntp/player (web): ${message}`); } }