UNPKG

audio-tracker

Version:

A headless JavaScript library that gives you full control over web audio — playback, tracking, and Media Session integration made simple.

521 lines 17.8 kB
export { mediaSessionModule } from "./modules/mediaSessionModule"; export { timestampModule } from "./modules/timestampModule"; /** * A headless JavaScript library that gives you full control over web audio with advanced tracking and control features. * Provides a clean API for audio manipulation, event handling, and modular extensions. */ export default class AudioTracker { constructor(audioSource, options = {}) { if (audioSource instanceof HTMLAudioElement) { this.audio = audioSource; this.isExternalAudio = true; } else if (typeof audioSource === "string") { this.audio = new Audio(audioSource); this.isExternalAudio = false; } else { throw new Error("AudioTracker: audioSource must be either a string URL or HTMLAudioElement"); } this.options = options; if (!this.isExternalAudio) { this.audio.preload = options.preload || "metadata"; this.audio.loop = options.loop || false; this.audio.muted = options.muted || false; this.audio.autoplay = options.autoplay || false; if (options.crossOrigin) { this.audio.crossOrigin = options.crossOrigin; } if (typeof options.volume === "number") { this.audio.volume = Math.min(Math.max(options.volume, 0), 100) / 100; } } this.duration = 0; this.previousMutedState = this.audio.muted; this.callbacks = {}; this.subscribers = {}; this.boundHandlers = {}; this.cleanupFunctions = []; } // ============================================ // Event System // ============================================ /** * Subscribe to audio element DOM events * @param eventName - Name of the audio event (e.g., 'play', 'pause', 'timeupdate') * @param callback - Function to execute when event fires * @example * tracker.subscribe('play', () => console.log('Audio started playing')); */ subscribe(eventName, callback) { if (!this.subscribers[eventName]) { this.subscribers[eventName] = []; this._attachDOMListener(eventName); } this.subscribers[eventName].push(callback); } /** * Unsubscribe from audio element DOM events * @param eventName - Name of the audio event * @param callback - The specific callback function to remove * @example * const handler = () => console.log('Playing'); * tracker.subscribe('play', handler); * tracker.unsubscribe('play', handler); */ unsubscribe(eventName, callback) { if (!this.subscribers[eventName]) return; this.subscribers[eventName] = this.subscribers[eventName].filter((cb) => cb !== callback); if (this.subscribers[eventName].length === 0) { this._detachDOMListener(eventName); delete this.subscribers[eventName]; } } _attachDOMListener(eventName) { if (this.boundHandlers[eventName]) { return; } const handler = (event) => { if (this.subscribers[eventName]) { this.subscribers[eventName].forEach((callback) => callback(event)); } }; this.boundHandlers[eventName] = handler; this.audio.addEventListener(eventName, handler); } _detachDOMListener(eventName) { if (this.boundHandlers[eventName]) { this.audio.removeEventListener(eventName, this.boundHandlers[eventName]); delete this.boundHandlers[eventName]; } } // ============================================ // Initialization with callbacks // ============================================ /** * Initialize AudioTracker with callback functions for various audio events * @param callbacks - Object containing callback functions for audio events * @returns The AudioTracker instance for method chaining * @example * tracker.init({ * onPlay: () => console.log('Playing'), * onTimeUpdate: (time) => console.log(`Current time: ${time}`), * onEnded: () => console.log('Playback finished') * }); */ init(callbacks = {}) { this.callbacks = { ...this.callbacks, ...callbacks }; if (callbacks.onPlay) { this.subscribe("play", () => this.callbacks.onPlay?.()); } if (callbacks.onPause) { this.subscribe("pause", () => this.callbacks.onPause?.()); } if (callbacks.onEnded) { this.subscribe("ended", () => this.callbacks.onEnded?.()); } if (callbacks.onTimeUpdate) { this.subscribe("timeupdate", () => { this.callbacks.onTimeUpdate?.(this.audio.currentTime); }); } if (callbacks.onRateChange) { this.subscribe("ratechange", () => { this.callbacks.onRateChange?.(this.audio.playbackRate); }); } if (callbacks.onVolumeChange || callbacks.onMuteChange) { this.subscribe("volumechange", () => { const currentMutedState = this.audio.muted; if (currentMutedState !== this.previousMutedState) { this.callbacks.onMuteChange?.(currentMutedState); this.previousMutedState = currentMutedState; } this.callbacks.onVolumeChange?.(this.audio.volume * 100); }); } if (callbacks.onBufferChange || callbacks.onBufferPercentageChange) { this.subscribe("progress", () => { this.updateBuffer(); }); } if (callbacks.onPlaying) { this.subscribe("playing", () => this.callbacks.onPlaying?.()); } if (callbacks.onSeeking) { this.subscribe("seeking", () => { this.callbacks.onSeeking?.(this.audio.currentTime); }); } if (callbacks.onLoadStart) { this.subscribe("loadstart", () => this.callbacks.onLoadStart?.()); } if (callbacks.onCanPlay) { this.subscribe("canplay", () => this.callbacks.onCanPlay?.()); } if (callbacks.onWaiting) { this.subscribe("waiting", () => this.callbacks.onWaiting?.()); } if (callbacks.onStalled) { this.subscribe("stalled", () => this.callbacks.onStalled?.()); } if (callbacks.onError) { this.subscribe("error", () => this.callbacks.onError?.(this.audio.error)); } if (this.audio.readyState >= 1) { this.duration = this.audio.duration; callbacks.onDurationChange?.(this.duration); if (callbacks.onBufferChange || callbacks.onBufferPercentageChange) { this.updateBuffer(); } } else if (callbacks.onDurationChange) { this.subscribe("loadedmetadata", () => { this.duration = this.audio.duration; this.callbacks.onDurationChange?.(this.duration); if (callbacks.onBufferChange || callbacks.onBufferPercentageChange) { this.updateBuffer(); } }); } return this; } updateBuffer() { if (this.audio.buffered.length > 0) { const bufferedTime = this.audio.buffered.end(this.audio.buffered.length - 1); this.callbacks.onBufferChange?.(bufferedTime); if (this.duration > 0) { const bufferedPercentage = (bufferedTime / this.duration) * 100; this.callbacks.onBufferPercentageChange?.(bufferedPercentage); } } } // ============================================ // Module System // ============================================ /** * Extend AudioTracker functionality with a module * @param module - Function that receives the tracker instance and returns an optional cleanup function * @returns The AudioTracker instance for method chaining * @example * tracker.use(mediaSessionModule).use(timestampModule); */ use(module) { const cleanup = module(this); if (typeof cleanup === "function") { this.cleanupFunctions.push(cleanup); } return this; } // ============================================ // Controls // ============================================ /** * Start audio playback * @returns Promise that resolves when playback begins * @example * await tracker.play(); */ play() { return this.audio.play().catch((e) => { console.warn("Playback failed:", e); }); } /** * Pause audio playback * @example * tracker.pause(); */ pause() { this.audio.pause(); } /** * Toggle playback state between play and pause * @returns Promise that resolves when playback starts or void if paused * @example * await tracker.togglePlay(); */ togglePlay() { if (this.isPlaying()) { this.pause(); } else { return this.play(); } } /** * Seek to a specific time position in the audio * @param time - Time position in seconds * @example * tracker.seekTo(30); // Jump to 30 seconds */ seekTo(time) { const duration = this.duration || this.audio.duration || 0; this.audio.currentTime = Math.max(0, Math.min(time, duration)); } /** * Skip forward by a specified number of seconds * @param seconds - Number of seconds to skip forward (default: 10) * @example * tracker.forward(15); // Skip forward 15 seconds */ forward(seconds = 10) { const newTime = Math.min(this.audio.currentTime + seconds, this.duration); this.audio.currentTime = newTime; } /** * Skip backward by a specified number of seconds * @param seconds - Number of seconds to skip backward (default: 10) * @example * tracker.backward(15); // Skip back 15 seconds */ backward(seconds = 10) { const newTime = Math.max(this.audio.currentTime - seconds, 0); this.audio.currentTime = newTime; } /** * Set the audio volume level * @param value - Volume level from 0 to 100 * @example * tracker.setVolume(75); // Set volume to 75% */ setVolume(value) { if (typeof value === "number") { const clampedValue = Math.max(0, Math.min(value, 100)); this.audio.volume = clampedValue / 100; } } /** * Get the current volume level * @returns Current volume as a percentage (0-100) * @example * const volume = tracker.getVolume(); // Returns 75 */ getVolume() { return this.audio.volume * 100; } /** * Set the muted state of the audio * @param muted - True to mute, false to unmute * @example * tracker.setMuted(true); // Mute audio */ setMuted(muted) { this.audio.muted = Boolean(muted); } /** * Toggle the muted state of the audio * @returns The new muted state (true if now muted, false if unmuted) * @example * const isMuted = tracker.toggleMute(); // Toggle and get new state */ toggleMute() { this.audio.muted = !this.audio.muted; this.callbacks.onMuteChange?.(this.audio.muted); return this.audio.muted; } /** * Check if audio is currently muted * @returns True if muted, false otherwise * @example * if (tracker.isMuted()) console.log('Audio is muted'); */ isMuted() { return this.audio.muted; } /** * Set the playback speed rate * @param rate - Playback rate (0.5 = half speed, 1.0 = normal, 2.0 = double speed) * @example * tracker.setPlaybackRate(1.5); // Play at 1.5x speed */ setPlaybackRate(rate) { this.audio.playbackRate = rate; } /** * Get the current playback speed rate * @returns Current playback rate * @example * const rate = tracker.getPlaybackRate(); // Returns 1.5 */ getPlaybackRate() { return this.audio.playbackRate; } /** * Enable or disable audio looping * @param loop - True to enable loop, false to disable * @example * tracker.setLoop(true); // Enable continuous looping */ setLoop(loop) { this.audio.loop = Boolean(loop); } /** * Check if audio looping is enabled * @returns True if looping is enabled * @example * if (tracker.isLooping()) console.log('Loop is enabled'); */ isLooping() { return this.audio.loop; } /** * Enable or disable autoplay * @param autoplay - True to enable autoplay, false to disable * @example * tracker.setAutoplay(true); // Enable autoplay */ setAutoplay(autoplay) { this.audio.autoplay = Boolean(autoplay); } /** * Check if autoplay is enabled * @returns True if autoplay is enabled * @example * const hasAutoplay = tracker.getAutoplay(); */ getAutoplay() { return this.audio.autoplay; } /** * Set CORS settings for the audio element * @param crossOrigin - CORS setting ('anonymous', 'use-credentials', or null) * @example * tracker.setCrossOrigin('anonymous'); */ setCrossOrigin(crossOrigin) { this.audio.crossOrigin = crossOrigin; } /** * Get the current CORS setting * @returns Current crossOrigin value * @example * const cors = tracker.getCrossOrigin(); */ getCrossOrigin() { return this.audio.crossOrigin; } /** * Set the preload behavior for the audio * @param preload - Preload setting ('none', 'metadata', or 'auto') * @example * tracker.setPreload('metadata'); // Preload only metadata */ setPreload(preload) { this.audio.preload = preload; } /** * Get the current preload setting * @returns Current preload value * @example * const preload = tracker.getPreload(); */ getPreload() { return this.audio.preload; } /** * Get the ready state of the audio element * @returns Ready state (0: HAVE_NOTHING, 1: HAVE_METADATA, 2: HAVE_CURRENT_DATA, 3: HAVE_FUTURE_DATA, 4: HAVE_ENOUGH_DATA) * @example * const state = tracker.getReadyState(); */ getReadyState() { return this.audio.readyState; } /** * Get the network state of the audio element * @returns Network state (0: NETWORK_EMPTY, 1: NETWORK_IDLE, 2: NETWORK_LOADING, 3: NETWORK_NO_SOURCE) * @example * const networkState = tracker.getNetworkState(); */ getNetworkState() { return this.audio.networkState; } /** * Check if audio is currently playing * @returns True if audio is playing, false otherwise * @example * if (tracker.isPlaying()) console.log('Audio is playing'); */ isPlaying() { return !this.audio.paused && !this.audio.ended && this.audio.readyState > 2; } /** * Get the total duration of the audio * @returns Duration in seconds * @example * const duration = tracker.getDuration(); // Returns 180 (3 minutes) */ getDuration() { return this.duration; } /** * Get the current playback time position * @returns Current time in seconds * @example * const currentTime = tracker.getCurrentTime(); // Returns 45.5 */ getCurrentTime() { return this.audio.currentTime; } /** * Get the remaining time until audio ends * @returns Remaining time in seconds * @example * const remaining = tracker.getTimeRemaining(); // Returns 134.5 */ getTimeRemaining() { return Math.max(0, this.duration - this.audio.currentTime); } // ============================================ // Utilities // ============================================ /** * Format seconds into MM:SS format * @param seconds - Time in seconds to format * @returns Formatted time string (e.g., "3:45") * @example * const formatted = tracker.formatTime(225); // Returns "3:45" */ formatTime(seconds) { if (!seconds || isNaN(seconds)) return "0:00"; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs < 10 ? "0" : ""}${secs}`; } /** * Get direct access to the underlying HTMLAudioElement * @returns The HTMLAudioElement instance * @example * const audioElement = tracker.getAudioElement(); * audioElement.addEventListener('canplaythrough', callback); */ getAudioElement() { return this.audio; } // ============================================ // Cleanup // ============================================ /** * Clean up all resources and event listeners. Call this when done using the tracker. * @example * tracker.destroy(); // Clean up before removing component */ destroy() { this.cleanupFunctions.forEach((fn) => fn()); this.cleanupFunctions = []; Object.keys(this.boundHandlers).forEach((eventName) => { this._detachDOMListener(eventName); }); this.audio.pause(); if (!this.isExternalAudio) { this.audio.src = ""; this.audio.load(); } this.subscribers = {}; this.callbacks = {}; } } //# sourceMappingURL=index.js.map