import { Link, Locator, LocatorLocations, Publication, Timeline, TimelineItem } from "@readium/shared"; import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig, KeyboardPeripheralEventData } from "../Navigator.ts"; import { Configurable } from "../preferences/Configurable.ts"; import { WebAudioEngine, PlaybackState } from "./engine/index.ts"; import { AudioPreferences, AudioDefaults, AudioSettings, AudioPreferencesEditor, IAudioPreferences, IAudioDefaults } from "./preferences/index.ts"; import { AudioPoolManager } from "./AudioPoolManager.ts"; import { ContextMenuEvent, SuspiciousActivityEvent } from "@readium/navigator-html-injectables"; import { AudioNavigatorProtector } from "./protection/AudioNavigatorProtector.ts"; import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector.ts"; import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals.ts"; export interface AudioMetadata { duration: number; textTracks: TextTrackList; readyState: number; networkState: number; } export interface AudioNavigatorListeners { trackLoaded: (media: HTMLMediaElement) => void; positionChanged: (locator: Locator) => void; timelineItemChanged: (item: TimelineItem | undefined) => void; error: (error: any, locator: Locator) => void; trackEnded: (locator: Locator) => void; play: (locator: Locator) => void; pause: (locator: Locator) => void; metadataLoaded: (metadata: AudioMetadata) => void; stalled: (isStalled: boolean) => void; seeking: (isSeeking: boolean) => void; seekable: (seekable: TimeRanges) => void; contentProtection: (type: string, data: SuspiciousActivityEvent) => void; peripheral: (data: KeyboardPeripheralEventData) => void; contextMenu: (data: ContextMenuEvent) => void; remotePlaybackStateChanged: (state: RemotePlaybackState) => void; } const defaultListeners = (listeners: Partial): AudioNavigatorListeners => ({ trackLoaded: listeners.trackLoaded ?? (() => {}), positionChanged: listeners.positionChanged ?? (() => {}), timelineItemChanged: listeners.timelineItemChanged ?? (() => {}), error: listeners.error ?? (() => {}), trackEnded: listeners.trackEnded ?? (() => {}), play: listeners.play ?? (() => {}), pause: listeners.pause ?? (() => {}), metadataLoaded: listeners.metadataLoaded ?? (() => {}), stalled: listeners.stalled ?? (() => {}), seeking: listeners.seeking ?? (() => {}), seekable: listeners.seekable ?? (() => {}), contentProtection: listeners.contentProtection ?? (() => {}), peripheral: listeners.peripheral ?? (() => {}), contextMenu: listeners.contextMenu ?? (() => {}), remotePlaybackStateChanged: listeners.remotePlaybackStateChanged ?? (() => {}), }); export interface IAudioContentProtectionConfig extends IContentProtectionConfig { /** Prevents the media element from being cast to remote devices via the Remote Playback API. */ disableRemotePlayback?: boolean; } export interface AudioNavigatorConfiguration { preferences: IAudioPreferences; defaults: IAudioDefaults; contentProtection?: IAudioContentProtectionConfig; keyboardPeripherals?: IKeyboardPeripheralsConfig; } export class AudioNavigator extends MediaNavigator implements Configurable { private readonly pub: Publication; private positionPollInterval: ReturnType | null = null; private navigationId: number = 0; private _playIntent: boolean = false; private listeners: AudioNavigatorListeners; private currentLocation!: Locator; private _preferences: AudioPreferences; private _defaults: AudioDefaults; private _settings: AudioSettings; private _preferencesEditor: AudioPreferencesEditor | null = null; private _mediaSessionEnabled: boolean = false; private pool: AudioPoolManager; private readonly _navigatorProtector: AudioNavigatorProtector | null = null; private _currentTimelineItem: TimelineItem | undefined; private readonly _keyboardPeripheralsManager: KeyboardPeripherals | null = null; private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null; private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null; private readonly _contentProtection: IAudioContentProtectionConfig; /** True while a track transition is in progress; suppresses spurious mid-navigation events. */ private _isNavigating: boolean = false; private _isStalled: boolean = false; private _stalledWatchdog: ReturnType | null = null; private _stalledCheckTime: number = 0; constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration: AudioNavigatorConfiguration = { preferences: {}, defaults: {} }) { super(); this.pub = publication; this.listeners = defaultListeners(listeners); this._preferences = new AudioPreferences(configuration.preferences); this._defaults = new AudioDefaults(configuration.defaults); this._settings = new AudioSettings(this._preferences, this._defaults); if (publication.readingOrder.items.length === 0) { throw new Error("AudioNavigator: publication has an empty reading order"); } if (initialPosition) { this.currentLocation = this.ensureLocatorLocations(initialPosition); } else { const firstLink = this.pub.readingOrder.items[0]; this.currentLocation = new Locator({ href: firstLink.href, type: firstLink.type || "audio/mpeg", title: firstLink.title, locations: new LocatorLocations({ position: 1, progression: 0, totalProgression: 0, fragments: ["t=0"] }) }); } const initialHref = this.currentLocation.href.split("#")[0]; const trackIndex = this.hrefToTrackIndex(initialHref); if (trackIndex === -1) { throw new Error(`AudioNavigator: initial href "${ initialHref }" not found in reading order`); } const initialTime = this.currentLocation.locations?.time() || 0; const audioEngine = new WebAudioEngine({ playback: { state: { currentTime: initialTime, duration: 0, } as PlaybackState, playWhenReady: false, index: trackIndex } }); this.pool = new AudioPoolManager(audioEngine, publication, configuration.contentProtection); // Initialize content protection const contentProtection = configuration.contentProtection || {}; this._contentProtection = contentProtection; const keyboardPeripherals = this.mergeKeyboardPeripherals( contentProtection, configuration.keyboardPeripherals || [] ); if (contentProtection.disableContextMenu || contentProtection.checkAutomation || contentProtection.checkIFrameEmbedding || contentProtection.monitorDevTools || contentProtection.protectPrinting?.disable || contentProtection.disableDragAndDrop || contentProtection.protectCopy) { this._navigatorProtector = new AudioNavigatorProtector(contentProtection); this._suspiciousActivityListener = (event: Event) => { const { type, ...detail } = (event as CustomEvent).detail; if (type === "context_menu") { this.listeners.contextMenu(detail as ContextMenuEvent); } else { this.listeners.contentProtection(type, detail as SuspiciousActivityEvent); } }; window.addEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener); } if (keyboardPeripherals.length > 0) { this._keyboardPeripheralsManager = new KeyboardPeripherals({ keyboardPeripherals }); this._keyboardPeripheralListener = (event: Event) => { this.listeners.peripheral((event as CustomEvent).detail); }; window.addEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener); } this.setupEventListeners(); this._isNavigating = true; this.pool.setCurrentAudio(trackIndex, "forward"); // applyPreferences() must come after setCurrentAudio() so that the src // is already set on the media element when setPlaybackRate() tries to // activate the Web Audio graph for the preservePitch polyfill path. this.applyPreferences(); // Load and seek to initial position, then notify consumer. // No cancellation needed here — the constructor runs once. this.waitForLoadedAndSeeked(initialTime) .then(() => { this._isNavigating = false; this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement()); this._notifyTimelineChange(this.currentLocator); this.listeners.positionChanged(this.currentLocator); this._setupRemotePlayback(); }) .catch(() => { this._isNavigating = false; // Error already forwarded via the error event listener. }); } get settings(): AudioSettings { return this._settings; } get preferencesEditor(): AudioPreferencesEditor { if (this._preferencesEditor === null) { this._preferencesEditor = new AudioPreferencesEditor(this._preferences, this.settings); } return this._preferencesEditor; } async submitPreferences(preferences: AudioPreferences) { this._preferences = this._preferences.merging(preferences) as AudioPreferences; this.applyPreferences(); } private applyPreferences(): void { this._settings = new AudioSettings(this._preferences, this._defaults); if (this._preferencesEditor !== null) { this._preferencesEditor = new AudioPreferencesEditor(this._preferences, this.settings); } this.pool.audioEngine.setVolume(this._settings.volume); this.pool.audioEngine.setPlaybackRate(this._settings.playbackRate, this._settings.preservePitch); if (this.positionPollInterval !== null) this.startPositionPolling(); if (this._settings.enableMediaSession && !this._mediaSessionEnabled) { this._mediaSessionEnabled = true; this.setupMediaSession(); } else if (!this._settings.enableMediaSession && this._mediaSessionEnabled) { this._mediaSessionEnabled = false; this.destroyMediaSession(); } } get publication(): Publication { return this.pub; } get timeline(): Timeline { return this.pub.timeline; } private _notifyTimelineChange(locator: Locator): void { const item = this.pub.timeline.locate(locator); if (item !== this._currentTimelineItem) { this._currentTimelineItem = item; this.listeners.timelineItemChanged(item); if (this._settings.enableMediaSession) { this.updateMediaSessionMetadata(); } } } private ensureLocatorLocations(locator: Locator): Locator { return new Locator({ ...locator, locations: locator.locations instanceof LocatorLocations ? locator.locations : locator.locations ? new LocatorLocations(locator.locations) : undefined }); } /** Resolves a bare href (no fragment) to its index in the reading order. Returns -1 if not found. */ private hrefToTrackIndex(href: string): number { const bare = href.split("#")[0]; return this.pub.readingOrder.items.findIndex(item => item.href === bare); } /** Current track index derived from the current location's href. */ private currentTrackIndex(): number { return this.hrefToTrackIndex(this.currentLocation.href); } get currentLocator(): Locator { return this.currentLocation; } get isPlaying(): boolean { return this.pool.audioEngine.isPlaying(); } get isPaused(): boolean { return this.pool.audioEngine.isPaused(); } get duration(): number { return this.pool.audioEngine.duration(); } get currentTime(): number { return this.pool.audioEngine.currentTime(); } private createLocator(trackIndex: number, timestamp: number): Locator { const link = this.pub.readingOrder.items[trackIndex]; if (!link) throw new Error(`Invalid track index: ${trackIndex}`); const duration = this.pool.audioEngine.duration(); return new Locator({ href: link.href, type: link.type || "audio/mpeg", title: link.title, locations: new LocatorLocations({ progression: duration > 0 ? timestamp / duration : 0, position: trackIndex + 1, fragments: [`t=${timestamp}`] }) }); } /** * Waits for the current audio to be ready to play, then seeks to seekTime if > 0. * Rejects if an error event fires before the audio is ready. * When navId is provided, skips the seek if that navigation has been superseded. */ private waitForLoadedAndSeeked(seekTime: number, navId?: number): Promise { return new Promise((resolve, reject) => { const proceed = () => { if (navId !== undefined && navId !== this.navigationId) { resolve(); return; } if (seekTime <= 0) { resolve(); return; } const onSeeked = () => { this.pool.audioEngine.off("seeked", onSeeked); resolve(); }; this.pool.audioEngine.on("seeked", onSeeked); this.seek(seekTime); }; if (this.pool.audioEngine.isLoaded()) { proceed(); return; } const onReady = () => { this.pool.audioEngine.off("canplaythrough", onReady); this.pool.audioEngine.off("error", onError); proceed(); }; const onError = (err: any) => { this.pool.audioEngine.off("canplaythrough", onReady); this.pool.audioEngine.off("error", onError); reject(err); }; this.pool.audioEngine.on("canplaythrough", onReady); this.pool.audioEngine.on("error", onError); }); } private setupEventListeners(): void { this.pool.audioEngine.on("error", (error: any) => { this.listeners.error(error, this.currentLocator); }); this.pool.audioEngine.on("ended", async () => { this.stopPositionPolling(); this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({ position: this.currentTrackIndex() + 1, progression: 1, fragments: [`t=${this.duration}`] })); this.listeners.trackEnded(this.currentLocator); if (!this.canGoForward) return; await this.nextTrack(); if (this._settings.autoPlay) this.play(); }); this.pool.audioEngine.on("play", () => { if (this._isNavigating) return; this.startPositionPolling(); this.listeners.play(this.currentLocator); }); this.pool.audioEngine.on("playing", () => { if (this._isNavigating) return; this._setStalled(false); }); this.pool.audioEngine.on("pause", () => { if (this._isNavigating) return; this.stopPositionPolling(); this.listeners.pause(this.currentLocator); }); this.pool.audioEngine.on("seeked", () => { if (this._isNavigating) return; this.listeners.seeking(false); const currentTime = this.currentTime; const duration = this.duration; const progression = duration > 0 ? currentTime / duration : 0; this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({ position: this.currentTrackIndex() + 1, progression, fragments: [`t=${currentTime}`] })); // Always notify on seeked — don't defer to polling — so that a skip // crossing a timeline item boundary fires timelineItemChanged immediately // regardless of play state. _notifyTimelineChange deduplicates internally. this._notifyTimelineChange(this.currentLocation); this.listeners.positionChanged(this.currentLocation); }); this.pool.audioEngine.on("seeking", () => { if (!this._isNavigating) this.listeners.seeking(true); }); this.pool.audioEngine.on("waiting", () => { if (!this._isNavigating) this.listeners.seeking(true); }); this.pool.audioEngine.on("stalled", () => { if (!this._isNavigating) this._setStalled(true); }); this.pool.audioEngine.on("canplaythrough", () => { if (!this._isNavigating) this._setStalled(false); }); this.pool.audioEngine.on("progress", (seekable: TimeRanges) => { if (!this._isNavigating) this.listeners.seekable(seekable); }); this.pool.audioEngine.on("loadedmetadata", () => { const mediaElement = this.pool.audioEngine.getMediaElement(); const metadata: AudioMetadata = { duration: this.pool.audioEngine.duration(), textTracks: mediaElement.textTracks, readyState: mediaElement.readyState, networkState: mediaElement.networkState }; this.listeners.metadataLoaded(metadata); }); } private _setStalled(isStalled: boolean): void { if (this._isStalled === isStalled) return; this._isStalled = isStalled; this.listeners.stalled(isStalled); if (isStalled) { this._stalledCheckTime = this.currentTime; this._startStalledWatchdog(); } else { this._stopStalledWatchdog(); } } private _startStalledWatchdog(): void { this._stalledWatchdog = setInterval(() => { if (!this.isPlaying) { this._setStalled(false); return; } const t = this.currentTime; if (t !== this._stalledCheckTime) { this._setStalled(false); } this._stalledCheckTime = t; }, 500); } private _stopStalledWatchdog(): void { if (this._stalledWatchdog !== null) { clearInterval(this._stalledWatchdog); this._stalledWatchdog = null; } } private setupMediaSession(): void { if (!("mediaSession" in navigator)) return; navigator.mediaSession.setActionHandler("play", () => this.play()); navigator.mediaSession.setActionHandler("pause", () => this.pause()); navigator.mediaSession.setActionHandler("previoustrack", () => this.goBackward(false, () => {})); navigator.mediaSession.setActionHandler("nexttrack", () => this.goForward(false, () => {})); navigator.mediaSession.setActionHandler("seekbackward", (details) => this.jump(-(details.seekOffset || 10))); navigator.mediaSession.setActionHandler("seekforward", (details) => this.jump(details.seekOffset || 10)); this.updateMediaSessionMetadata(); } private updateMediaSessionMetadata(): void { if (!("mediaSession" in navigator)) return; const trackIndex = this.currentTrackIndex(); const track = this.pub.readingOrder.items[trackIndex]; const cover = this.pub.getCover(); navigator.mediaSession.metadata = new MediaMetadata({ title: track?.title || `Track ${trackIndex + 1}`, artist: this.pub.metadata.authors ? this.pub.metadata.authors.items.map((a) => a.name.getTranslation()).join(", ") : undefined, album: this.pub.metadata.title.getTranslation(), artwork: cover ? [{ src: cover.toURL(this.pub.baseURL) ?? cover.href, type: cover.type }] : undefined, }); } private startPositionPolling(): void { this.stopPositionPolling(); this.positionPollInterval = setInterval(() => { const currentTime = this.currentTime; const duration = this.duration; const progression = duration > 0 ? currentTime / duration : 0; this.currentLocation = this.currentLocation.copyWithLocations(new LocatorLocations({ position: this.currentTrackIndex() + 1, progression, fragments: [`t=${currentTime}`] })); this._notifyTimelineChange(this.currentLocation); this.listeners.positionChanged(this.currentLocation); }, this._settings.pollInterval); } private stopPositionPolling(): void { if (this.positionPollInterval !== null) { clearInterval(this.positionPollInterval); this.positionPollInterval = null; } } async go(locator: Locator, _animated: boolean, cb: (ok: boolean) => void): Promise { try { locator = this.ensureLocatorLocations(locator); const href = locator.href.split("#")[0]; const trackIndex = this.hrefToTrackIndex(href); const time = locator.locations?.time() || 0; if (trackIndex === -1) { cb(false); return; } const id = ++this.navigationId; const previousTrackIndex = this.currentTrackIndex(); const direction: "forward" | "backward" = trackIndex >= previousTrackIndex ? "forward" : "backward"; const wasPlaying = this.isPlaying || this._playIntent; this._playIntent = wasPlaying; this._isNavigating = true; this.stopPositionPolling(); this.pool.setCurrentAudio(trackIndex, direction); this.currentLocation = locator.copyWithLocations(locator.locations); await this.waitForLoadedAndSeeked(time, id); this._isNavigating = false; if (id !== this.navigationId) { cb(false); return; } if (trackIndex !== previousTrackIndex) { this.listeners.trackLoaded(this.pool.audioEngine.getMediaElement()); } this._notifyTimelineChange(this.currentLocator); this.listeners.positionChanged(this.currentLocator); if (this._settings.enableMediaSession) { this.updateMediaSessionMetadata(); } if (wasPlaying) this.play(); cb(true); } catch (error) { this._isNavigating = false; console.error("Failed to go to locator:", error); cb(false); } finally { this._playIntent = false; } } async goLink(link: Link, _animated: boolean, cb: (ok: boolean) => void): Promise { const trackIndex = this.hrefToTrackIndex(link.href); if (trackIndex === -1) { cb(false); return; } const time = link.locator.locations?.time() ?? 0; const locator = this.createLocator(trackIndex, time); await this.go(locator, _animated, cb); } async goForward(_animated: boolean, cb: (ok: boolean) => void): Promise { if (!this.canGoForward) { cb(false); return; } await this.nextTrack(); cb(true); } async goBackward(_animated: boolean, cb: (ok: boolean) => void): Promise { if (!this.canGoBackward) { cb(false); return; } await this.previousTrack(); cb(true); } play(): void { this.pool.audioEngine.play(); } pause(): void { this.pool.audioEngine.pause(); } stop(): void { this.pool.audioEngine.stop(); } private async nextTrack(): Promise { if (!this.canGoForward) return; const locator = this.createLocator(this.currentTrackIndex() + 1, 0); await this.go(locator, false, () => {}); } private async previousTrack(): Promise { if (!this.canGoBackward) return; const locator = this.createLocator(this.currentTrackIndex() - 1, 0); await this.go(locator, false, () => {}); } seek(time: number): void { this.pool.audioEngine.skip(time - this.pool.audioEngine.currentTime()); } jump(seconds: number): void { this.pool.audioEngine.skip(seconds); } skipForward(): void { this.pool.audioEngine.skip(this._settings.skipForwardInterval); } skipBackward(): void { this.pool.audioEngine.skip(-this._settings.skipBackwardInterval); } get isTrackStart(): boolean { return this.currentTrackIndex() === 0 && (this.currentLocation.locations?.time() || 0) === 0; } get isTrackEnd(): boolean { const trackIndex = this.currentTrackIndex(); if (trackIndex !== this.pub.readingOrder.items.length - 1) return false; const progression = this.currentLocation.locations?.progression; if (progression !== undefined) return progression >= 1; const link = this.pub.readingOrder.items[trackIndex]; const duration = this.duration || link?.duration || 0; return duration > 0 && (this.currentLocation.locations?.time() ?? 0) >= duration; } get canGoBackward(): boolean { return this.currentTrackIndex() > 0; } get canGoForward(): boolean { return this.currentTrackIndex() < this.pub.readingOrder.items.length - 1; } /** * The RemotePlayback object for the primary media element. * Because the element is never swapped, this reference is stable for the * lifetime of the navigator — host apps can store it and call `.prompt()`, * `.watchAvailability()`, etc. directly. */ get remotePlayback(): RemotePlayback | undefined { const el = this.pool.audioEngine.getMediaElement(); return "remote" in el ? el.remote : undefined; } /** Wires up the optional remotePlaybackStateChanged listener. Called once after initial load. */ private _setupRemotePlayback(): void { if (this._contentProtection.disableRemotePlayback) { return; } const remote = this.remotePlayback; if (!remote) return; remote.onconnecting = () => this.listeners.remotePlaybackStateChanged("connecting"); remote.onconnect = () => this.listeners.remotePlaybackStateChanged("connected"); remote.ondisconnect = () => this.listeners.remotePlaybackStateChanged("disconnected"); } private destroyMediaSession(): void { if (!("mediaSession" in navigator)) return; navigator.mediaSession.metadata = null; navigator.mediaSession.setActionHandler("play", null); navigator.mediaSession.setActionHandler("pause", null); navigator.mediaSession.setActionHandler("previoustrack", null); navigator.mediaSession.setActionHandler("nexttrack", null); navigator.mediaSession.setActionHandler("seekbackward", null); navigator.mediaSession.setActionHandler("seekforward", null); } destroy(): void { this.stopPositionPolling(); this._stopStalledWatchdog(); this.destroyMediaSession(); if (this._suspiciousActivityListener) { window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener); } if (this._keyboardPeripheralListener) { window.removeEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener); } this._navigatorProtector?.destroy(); this._keyboardPeripheralsManager?.destroy(); this.pool.destroy(); } }