UNPKG

audiotoolheadless

Version:

A modern, headless audio player with HLS support, equalizer, queue management, and media session integration

1,646 lines (1,637 loc) 55.6 kB
'use strict'; // src/config/index.ts var PLAYER_CONSTANTS = Object.freeze({ REACT: "REACT", VANILLA: "VANILLA", DEVELOPMENT: "development", PRODUCTION: "production" }); var PLAYBACK_STATES = Object.freeze({ BUFFERING: "buffering", PLAYING: "playing", PAUSED: "paused", READY: "ready", IDLE: "idle", ENDED: "ended", STALLED: "stalled", ERROR: "error", TRACK_CHANGE: "trackchanged", DURATION_CHANGE: "durationchanged", QUEUE_ENDED: "queueended" }); var READY_STATES = Object.freeze({ HAVE_NOTHING: 0, HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3, HAVE_ENOUGH_DATA: 4 }); var NETWORK_STATES = Object.freeze({ NETWORK_EMPTY: 0, NETWORK_IDLE: 1, NETWORK_LOADING: 2, NETWORK_NO_SOURCE: 3 }); var ERROR_MESSAGES = Object.freeze({ MEDIA_ERR_ABORTED: "The user canceled the audio.", MEDIA_ERR_DECODE: "An error occurred while decoding the audio.", MEDIA_ERR_NETWORK: "A network error occurred while fetching the audio.", MEDIA_ERR_SRC_NOT_SUPPORTED: "The audio is missing or is in a format not supported by your browser.", DEFAULT: "An unknown error occurred." }); var EXTERNAL_URLS = Object.freeze({ HLS_CDN: "https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.18/hls.min.js", CAST_SDK: "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" }); var EQUALIZER_PRESETS = Object.freeze({ FLAT: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ACOUSTIC: [-0.5, 0, 0.5, 0.8, 0.8, 0.5, 0, 0.2, 0.5, 0.8], BASS_BOOSTER: [2.4, 1.8, 1.2, 0.5, 0, 0, 0, 0, 0, 0], BASS_REDUCER: [-2.4, -1.8, -1.2, -0.5, 0, 0, 0, 0, 0, 0], CLASSICAL: [0, 0, 0, 0, 0, 0, 0, -1.2, -1.2, -1.2], DEEP: [1.2, 0.8, 1.6, 1.3, 0, 1.5, 2.4, 2.2, 1.8, 1.4], ELECTRONIC: [2.4, 1.8, 1, 0, -0.5, 2, 1, 1.8, 2.4, 2.4], LATIN: [0, 0, 0, 0, -1, -1, -1, 0, 1.2, 1.8], LOUDNESS: [6.5, 4, 0, 0, -2, 0, -1, -4.5, 4, 1], LOUNGE: [-1.5, -0.5, -0.2, 1.8, 2.4, 1, 0, 0, 1.8, 0.2], PIANO: [1.5, 1, 0, 2.4, 3, 1.5, 3.5, 4, 3, 3.5], POP: [-1, -0.5, 0, 0.8, 1.4, 1.4, 0.8, 0, -0.5, -1], RNB: [3, 7, 5.5, 1.4, -2, -1.5, 2, 2.4, 2.8, 3.5], ROCK: [3.2, 2.5, -0.6, -0.8, -0.3, 0.4, 0.9, 1.1, 1.1, 1.1], SMALL_SPEAKERS: [2.4, 4, 3.5, 3, 1.5, 0, -1.5, -2, -2.5, -3], SPOKEN_WORD: [-2.5, -1, 0, 0.7, 1.8, 2.5, 2.5, 2, 1, 0], TREBLE_BOOSTER: [0, 0, 0, 0, 0, 1, 2, 3, 4, 4.5], TREBLE_REDUCER: [0, 0, 0, 0, 0, -1, -2, -3, -4, -4.5], VOCAL_BOOSTER: [-1.6, -3, -3, 1, 3.8, 3.8, 3, 2.4, 1.5, 0] }); var EQUALIZER_BANDS = [ { type: "peaking", frequency: 32, Q: 1, gain: 0 }, { type: "peaking", frequency: 64, Q: 1, gain: 0 }, { type: "peaking", frequency: 125, Q: 1, gain: 0 }, { type: "peaking", frequency: 250, Q: 1, gain: 0 }, { type: "peaking", frequency: 500, Q: 1, gain: 0 }, { type: "peaking", frequency: 1e3, Q: 1, gain: 0 }, { type: "peaking", frequency: 2e3, Q: 1, gain: 0 }, { type: "peaking", frequency: 4e3, Q: 1, gain: 0 }, { type: "peaking", frequency: 8e3, Q: 1, gain: 0 }, { type: "peaking", frequency: 16e3, Q: 1, gain: 0 } ]; var AUDIO_EVENTS = Object.freeze({ ABORT: "abort", TIME_UPDATE: "timeupdate", CAN_PLAY: "canplay", CAN_PLAY_THROUGH: "canplaythrough", DURATION_CHANGE: "durationchange", ENDED: "ended", EMPTIED: "emptied", PLAYING: "playing", WAITING: "waiting", SEEKING: "seeking", SEEKED: "seeked", LOADED_META_DATA: "loadedmetadata", LOADED_DATA: "loadeddata", PLAY: "play", PAUSE: "pause", RATE_CHANGE: "ratechange", VOLUME_CHANGE: "volumechange", SUSPEND: "suspend", STALLED: "stalled", PROGRESS: "progress", LOAD_START: "loadstart", ERROR: "error", TRACK_CHANGE: "trackchange", QUEUE_ENDED: "queueended" }); var HLS_EVENTS = Object.freeze({ MEDIA_ATTACHING: "hlsMediaAttaching", MEDIA_ATTACHED: "hlsMediaAttached", MEDIA_DETACHING: "hlsMediaDetaching", MEDIA_DETACHED: "hlsMediaDetached", BUFFER_RESET: "hlsBufferReset", BUFFER_CODECS: "hlsBufferCodecs", BUFFER_CREATED: "hlsBufferCreated", BUFFER_APPENDING: "hlsBufferAppending", BUFFER_APPENDED: "hlsBufferAppended", BUFFER_EOS: "hlsBufferEos", BUFFER_FLUSHING: "hlsBufferFlushing", BUFFER_FLUSHED: "hlsBufferFlushed", MANIFEST_LOADING: "hlsManifestLoading", MANIFEST_LOADED: "hlsManifestLoaded", MANIFEST_PARSED: "hlsManifestParsed", LEVEL_SWITCHING: "hlsLevelSwitching", LEVEL_SWITCHED: "hlsLevelSwitched", LEVEL_LOADING: "hlsLevelLoading", LEVEL_LOADED: "hlsLevelLoaded", LEVEL_UPDATED: "hlsLevelUpdated", LEVEL_PTS_UPDATED: "hlsLevelPtsUpdated", LEVELS_UPDATED: "hlsLevelsUpdated", AUDIO_TRACKS_UPDATED: "hlsAudioTracksUpdated", AUDIO_TRACK_SWITCHING: "hlsAudioTrackSwitching", AUDIO_TRACK_SWITCHED: "hlsAudioTrackSwitched", AUDIO_TRACK_LOADING: "hlsAudioTrackLoading", AUDIO_TRACK_LOADED: "hlsAudioTrackLoaded", SUBTITLE_TRACKS_UPDATED: "hlsSubtitleTracksUpdated", SUBTITLE_TRACKS_CLEARED: "hlsSubtitleTracksCleared", SUBTITLE_TRACK_SWITCH: "hlsSubtitleTrackSwitch", SUBTITLE_TRACK_LOADING: "hlsSubtitleTrackLoading", SUBTITLE_TRACK_LOADED: "hlsSubtitleTrackLoaded", SUBTITLE_FRAG_PROCESSED: "hlsSubtitleFragProcessed", CUES_PARSED: "hlsCuesParsed", NON_NATIVE_TEXT_TRACKS_FOUND: "hlsNonNativeTextTracksFound", INIT_PTS_FOUND: "hlsInitPtsFound", FRAG_LOADING: "hlsFragLoading", FRAG_LOAD_EMERGENCY_ABORTED: "hlsFragLoadEmergencyAborted", FRAG_LOADED: "hlsFragLoaded", FRAG_DECRYPTED: "hlsFragDecrypted", FRAG_PARSING_INIT_SEGMENT: "hlsFragParsingInitSegment", FRAG_PARSING_USERDATA: "hlsFragParsingUserdata", FRAG_PARSING_METADATA: "hlsFragParsingMetadata", FRAG_PARSED: "hlsFragParsed", FRAG_BUFFERED: "hlsFragBuffered", FRAG_CHANGED: "hlsFragChanged", FPS_DROP: "hlsFpsDrop", FPS_DROP_LEVEL_CAPPING: "hlsFpsDropLevelCapping", ERROR: "hlsError", DESTROYING: "hlsDestroying", KEY_LOADING: "hlsKeyLoading", KEY_LOADED: "hlsKeyLoaded", LIVE_BACK_BUFFER_REACHED: "hlsLiveBackBufferReached", BACK_BUFFER_REACHED: "hlsBackBufferReached" }); var ERROR_EVENTS = Object.freeze({ MEDIA_ERR_ABORTED: "Media playback was aborted", MEDIA_ERR_NETWORK: "Network error occurred", MEDIA_ERR_DECODE: "Media decoding error", MEDIA_ERR_SRC_NOT_SUPPORTED: "Media source not supported" }); var AUDIO_X_CONSTANTS = PLAYER_CONSTANTS; var PLAYBACK_STATE = PLAYBACK_STATES; var READY_STATE = READY_STATES; var ERROR_MSG_MAP = ERROR_MESSAGES; var URLS = EXTERNAL_URLS; // src/utils/event-emitter.ts var EventEmitter = class { constructor() { this.eventMap = {}; } /** * Subscribe to an event * @param eventName - Name of the event * @param callback - Function to call when event is emitted * @returns Unsubscribe function */ subscribe(eventName, callback) { if (!this.eventMap[eventName]) { this.eventMap[eventName] = []; } this.eventMap[eventName].push(callback); return () => { this.unsubscribe(eventName, callback); }; } /** * Unsubscribe from an event * @param eventName - Name of the event * @param callback - Function to remove */ unsubscribe(eventName, callback) { if (!this.eventMap[eventName]) return; const index = this.eventMap[eventName].indexOf(callback); if (index > -1) { this.eventMap[eventName].splice(index, 1); } } /** * Emit an event to all subscribers * @param eventName - Name of the event * @param data - Data to pass to callbacks */ emit(eventName, data) { if (!this.eventMap[eventName]) return; for (const callback of this.eventMap[eventName]) { try { callback(data); } catch (error) { console.error(`EventEmitter: Error in callback for event '${eventName}':`, error); } } } /** * Remove all listeners for an event * @param eventName - Name of the event */ removeAllListeners(eventName) { if (eventName) { delete this.eventMap[eventName]; } else { this.eventMap = {}; } } /** * Get the number of listeners for an event * @param eventName - Name of the event * @returns Number of listeners */ listenerCount(eventName) { return this.eventMap[eventName]?.length || 0; } /** * Get all event names that have listeners * @returns Array of event names */ eventNames() { return Object.keys(this.eventMap); } }; // src/utils/validation.ts var ValidationUtils = class _ValidationUtils { /** * Check if an array is valid and has elements * @param arr - Array to validate * @returns True if array is valid and not empty */ static isValidArray(arr) { return arr && Array.isArray(arr) && arr.length > 0; } /** * Check if a function is valid * @param fn - Function to validate * @returns True if function is valid */ static isValidFunction(fn) { return fn instanceof Function && typeof fn === "function"; } /** * Check if an object is valid and not null * @param obj - Object to validate * @returns True if object is valid */ static isValidObject(obj) { return obj !== null && typeof obj === "object" && !Array.isArray(obj); } /** * Check if a string is valid and not empty * @param str - String to validate * @returns True if string is valid */ static isValidString(str) { return typeof str === "string" && str.trim().length > 0; } /** * Check if a number is valid and finite * @param num - Number to validate * @returns True if number is valid */ static isValidNumber(num) { return typeof num === "number" && Number.isFinite(num); } /** * Check if a value is defined (not null or undefined) * @param value - Value to check * @returns True if value is defined */ static isDefined(value) { return value !== null && value !== void 0; } /** * Validate volume range (0-100) * @param volume - Volume value to validate * @returns True if volume is in valid range */ static isValidVolume(volume) { return _ValidationUtils.isValidNumber(volume) && volume >= 0 && volume <= 100; } /** * Validate time value (non-negative) * @param time - Time value to validate * @returns True if time is valid */ static isValidTime(time) { return _ValidationUtils.isValidNumber(time) && time >= 0; } }; // src/utils/queue-manager.ts var QueueManager = class { constructor() { this.queue = []; } /** * Set the queue with specific playback type * @param tracks - Array of tracks * @param playbackType - Type of playback ordering */ setQueue(tracks, playbackType) { if (!ValidationUtils.isValidArray(tracks)) { throw new Error("QueueManager: Invalid tracks array provided"); } const tracksCopy = [...tracks]; switch (playbackType) { case "DEFAULT": this.queue = tracksCopy; break; case "REVERSE": this.queue = tracksCopy.reverse(); break; case "SHUFFLE": this.queue = this.shuffleArray(tracksCopy); break; default: this.queue = tracksCopy; break; } } /** * Get the current queue * @returns Array of tracks */ getQueue() { return [...this.queue]; } /** * Add tracks to the queue * @param tracks - Single track or array of tracks */ addToQueue(tracks) { const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; this.queue.push(...tracksArray); } /** * Remove track from queue by ID * @param trackId - ID of track to remove * @returns True if track was removed */ removeFromQueue(trackId) { const initialLength = this.queue.length; this.queue = this.queue.filter((track) => track.id !== trackId); return this.queue.length < initialLength; } /** * Clear the entire queue */ clearQueue() { this.queue = []; } /** * Get queue length * @returns Number of tracks in queue */ getQueueLength() { return this.queue.length; } /** * Get track at specific index * @param index - Index of track * @returns Track at index or undefined */ getTrackAt(index) { return this.queue[index]; } /** * Find track index by ID * @param trackId - ID of track to find * @returns Index of track or -1 if not found */ findTrackIndex(trackId) { return this.queue.findIndex((track) => track.id === trackId); } /** * Move track to new position * @param fromIndex - Current index * @param toIndex - Target index * @returns True if move was successful */ moveTrack(fromIndex, toIndex) { if (fromIndex < 0 || fromIndex >= this.queue.length || toIndex < 0 || toIndex >= this.queue.length) { return false; } const track = this.queue.splice(fromIndex, 1)[0]; if (track) { this.queue.splice(toIndex, 0, track); } return true; } /** * Shuffle the queue using Fisher-Yates algorithm * @param currentTrackId - Optional current track ID to keep at current position * @returns Shuffled queue */ shuffleQueue(currentTrackId) { let currentTrack; let currentIndex = -1; if (currentTrackId) { currentIndex = this.findTrackIndex(currentTrackId); if (currentIndex > -1) { currentTrack = this.queue[currentIndex]; } } const shuffled = this.shuffleArray([...this.queue]); if (currentTrack && currentIndex > -1) { const newIndex = shuffled.findIndex((track) => track.id === currentTrackId); if (newIndex > -1 && newIndex !== currentIndex) { const trackAtCurrent = shuffled[currentIndex]; const trackAtNew = shuffled[newIndex]; if (trackAtCurrent && trackAtNew) { shuffled[currentIndex] = trackAtNew; shuffled[newIndex] = trackAtCurrent; } } } this.queue = shuffled; return [...this.queue]; } /** * Shuffle an array using Fisher-Yates algorithm * @param array - Array to shuffle * @returns New shuffled array */ shuffleArray(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const temp = shuffled[i]; const other = shuffled[j]; if (temp !== void 0 && other !== void 0) { shuffled[i] = other; shuffled[j] = temp; } } return shuffled; } /** * Get next track index with loop support * @param currentIndex - Current track index * @param loopMode - Current loop mode * @returns Next track index or -1 if no next track */ getNextTrackIndex(currentIndex, loopMode) { switch (loopMode) { case "SINGLE": return currentIndex; // Stay on same track case "QUEUE": return currentIndex + 1 >= this.queue.length ? 0 : currentIndex + 1; case "OFF": return currentIndex + 1 < this.queue.length ? currentIndex + 1 : -1; default: return currentIndex + 1 < this.queue.length ? currentIndex + 1 : -1; } } /** * Get previous track index * @param currentIndex - Current track index * @returns Previous track index or -1 if no previous track */ getPreviousTrackIndex(currentIndex) { return currentIndex > 0 ? currentIndex - 1 : -1; } /** * Check if queue is empty * @returns True if queue is empty */ isEmpty() { return this.queue.length === 0; } /** * Get queue statistics * @returns Object with queue statistics */ getQueueStats() { const totalTracks = this.queue.length; const totalDuration = this.queue.reduce((sum, track) => { return sum + (track.duration || 0); }, 0); const averageDuration = totalTracks > 0 ? totalDuration / totalTracks : 0; return { totalTracks, totalDuration, averageDuration }; } }; // src/utils/playback-utils.ts var _PlaybackUtils = class _PlaybackUtils { /** * Calculate actual played length for analytics * @param audioElement - HTML audio element * @param event - Event type that triggered calculation */ static calculateActualPlayedLength(audioElement, event) { if (!audioElement) return; const currentTime = Date.now(); audioElement.currentTime; switch (event) { case "PLAY": _PlaybackUtils.playbackStartTime = currentTime; break; case "PAUSE": case "ENDED": case "TRACK_CHANGE": if (_PlaybackUtils.playbackStartTime > 0) { const sessionDuration = currentTime - _PlaybackUtils.playbackStartTime; _PlaybackUtils.totalPlayTime += sessionDuration; _PlaybackUtils.playbackStartTime = 0; console.debug("PlaybackUtils: Session duration:", sessionDuration, "ms"); console.debug("PlaybackUtils: Total play time:", _PlaybackUtils.totalPlayTime, "ms"); } break; } } /** * Get total playback time * @returns Total playback time in milliseconds */ static getTotalPlayTime() { return _PlaybackUtils.totalPlayTime; } /** * Reset playback time tracking */ static resetPlayTime() { _PlaybackUtils.playbackStartTime = 0; _PlaybackUtils.totalPlayTime = 0; } /** * Handle loop playback mode * @param loopMode - The loop mode to apply */ static handleLoopPlayback(loopMode) { console.debug("PlaybackUtils: Loop mode set to:", loopMode); } /** * Handle queue playback progression */ static handleQueuePlayback() { console.debug("PlaybackUtils: Queue playback handler activated"); } /** * Calculate playback progress percentage * @param currentTime - Current playback time * @param duration - Total track duration * @returns Progress percentage (0-100) */ static calculateProgress(currentTime, duration) { if (!duration || duration === 0) return 0; return Math.min(100, currentTime / duration * 100); } /** * Calculate remaining time * @param currentTime - Current playback time * @param duration - Total track duration * @returns Remaining time in seconds */ static calculateRemainingTime(currentTime, duration) { if (!duration || duration === 0) return 0; return Math.max(0, duration - currentTime); } /** * Format time for display * @param seconds - Time in seconds * @returns Formatted time string (MM:SS or HH:MM:SS) */ static formatTime(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return "00:00"; const hours = Math.floor(seconds / 3600); const minutes = Math.floor(seconds % 3600 / 60); const secs = Math.floor(seconds % 60); const formatPart = (num) => num.toString().padStart(2, "0"); if (hours > 0) { return `${formatPart(hours)}:${formatPart(minutes)}:${formatPart(secs)}`; } return `${formatPart(minutes)}:${formatPart(secs)}`; } /** * Get buffered duration * @param audioElement - HTML audio element * @returns Buffered duration in seconds */ static getBufferedDuration(audioElement) { if (!audioElement.buffered || audioElement.buffered.length === 0) { return 0; } const buffered = audioElement.buffered; const currentTime = audioElement.currentTime; for (let i = 0; i < buffered.length; i++) { const start = buffered.start(i); const end = buffered.end(i); if (currentTime >= start && currentTime <= end) { return end - currentTime; } } return 0; } /** * Check if track can be played through without buffering * @param audioElement - HTML audio element * @returns True if track can play through */ static canPlayThrough(audioElement) { return audioElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA; } /** * Get playback rate adjusted duration * @param duration - Original duration * @param playbackRate - Current playback rate * @returns Adjusted duration */ static getAdjustedDuration(duration, playbackRate) { if (playbackRate <= 0) return duration; return duration / playbackRate; } /** * Smooth seek to prevent audio glitches * @param audioElement - HTML audio element * @param targetTime - Target time to seek to * @param smooth - Whether to use smooth seeking */ static seekTo(audioElement, targetTime, smooth = true) { if (!Number.isFinite(targetTime) || targetTime < 0) return; const duration = audioElement.duration; let adjustedTargetTime = targetTime; if (Number.isFinite(duration)) { adjustedTargetTime = Math.min(targetTime, duration - 0.1); } if (smooth && Math.abs(audioElement.currentTime - adjustedTargetTime) < 1) { const steps = 5; const stepSize = (adjustedTargetTime - audioElement.currentTime) / steps; let currentStep = 0; const smoothSeek = () => { if (currentStep < steps) { audioElement.currentTime += stepSize; currentStep++; setTimeout(smoothSeek, 20); } else { audioElement.currentTime = adjustedTargetTime; } }; smoothSeek(); } else { audioElement.currentTime = adjustedTargetTime; } } }; _PlaybackUtils.playbackStartTime = 0; _PlaybackUtils.totalPlayTime = 0; var PlaybackUtils = _PlaybackUtils; // src/lib/equalizer/index.ts var Equalizer = class _Equalizer { constructor() { if (_Equalizer.equalizerInstance) { console.warn( "Equalizer: Multiple instances detected. Returning existing **singleton** instance." ); return _Equalizer.equalizerInstance; } this.initializeAudioContext(); _Equalizer.equalizerInstance = this; } /** * Initialize AudioContext with browser compatibility * @private */ initializeAudioContext() { const contextOptions = { latencyHint: "playback", sampleRate: 44100 }; try { if (typeof AudioContext !== "undefined") { this.audioContext = new AudioContext(contextOptions); } else if (typeof window.webkitAudioContext !== "undefined") { this.audioContext = new window.webkitAudioContext(contextOptions); } else { throw new Error("Web Audio API is not supported in this browser"); } this.contextStatus = "RUNNING"; if (this.audioContext.state === "suspended") { this.addContextResumeListener(); } } catch (error) { console.error("Equalizer: Failed to initialize AudioContext:", error); this.contextStatus = "CLOSED"; } } /** * Add listener to resume AudioContext on user interaction * @private */ addContextResumeListener() { const resumeContext = () => { this.audioContext.resume(); setTimeout(() => { if (this.audioContext.state === "running") { document.body.removeEventListener("click", resumeContext, false); document.body.removeEventListener("touchstart", resumeContext, false); } }, 0); }; document.body.addEventListener("click", resumeContext, false); document.body.addEventListener("touchstart", resumeContext, false); } /** * Initialize all audio processing nodes * @param audioElement - The HTML audio element to connect */ initializeWithAudioElement(audioElement) { try { const audioSource = this.audioContext.createMediaElementSource(audioElement); this.filterBands = EQUALIZER_BANDS.map((bandConfig) => { const filter = this.audioContext.createBiquadFilter(); filter.type = bandConfig.type; filter.frequency.value = bandConfig.frequency; filter.gain.value = bandConfig.gain; filter.Q.value = bandConfig.Q || 1; return filter; }); this.bassBoostFilter = this.audioContext.createBiquadFilter(); this.bassBoostFilter.type = "lowshelf"; this.bassBoostFilter.frequency.value = 100; this.bassBoostFilter.gain.value = 0; this.compressorNode = this.audioContext.createDynamicsCompressor(); this.compressorNode.threshold.value = -24; this.compressorNode.knee.value = 30; this.compressorNode.ratio.value = 12; this.compressorNode.attack.value = 3e-3; this.compressorNode.release.value = 0.25; this.gainNode = this.audioContext.createGain(); this.gainNode.gain.value = 1; this.connectAudioChain(audioSource); this.contextStatus = "RUNNING"; } catch (error) { console.error("Equalizer: Failed to initialize audio nodes:", error); this.contextStatus = "CLOSED"; } } /** * Connect all audio nodes in the processing chain * @private */ connectAudioChain(audioSource) { if (this.filterBands.length === 0) return; const firstBand = this.filterBands[0]; if (firstBand) { audioSource.connect(firstBand); } for (let i = 0; i < this.filterBands.length - 1; i++) { const currentBand = this.filterBands[i]; const nextBand = this.filterBands[i + 1]; if (currentBand && nextBand) { currentBand.connect(nextBand); } } const lastBand = this.filterBands[this.filterBands.length - 1]; if (lastBand) { lastBand.connect(this.bassBoostFilter); } this.bassBoostFilter.connect(this.compressorNode); this.compressorNode.connect(this.gainNode); this.gainNode.connect(this.audioContext.destination); } /** * Apply equalizer preset * @param presetName - Name of the preset to apply */ setPreset(presetName) { const presetGains = EQUALIZER_PRESETS[presetName]; if (!presetGains) { throw new Error(`Equalizer: Preset '${String(presetName)}' not found`); } this.setCustomEQ(presetGains); } /** * Set custom equalizer gains * @param gains - Array of gain values (-12 to +12 dB) */ setCustomEQ(gains) { if (!ValidationUtils.isValidArray(gains)) { throw new Error("Equalizer: Invalid gains array provided"); } if (gains.length !== this.filterBands.length) { throw new Error( `Equalizer: Expected ${this.filterBands.length} gain values, received ${gains.length}` ); } const currentTime = this.audioContext.currentTime; const transitionTime = 0.05; for (let i = 0; i < this.filterBands.length; i++) { const band = this.filterBands[i]; const gainValue = gains[i]; if (band && gainValue !== void 0 && ValidationUtils.isValidNumber(gainValue)) { const clampedGain = Math.max(-12, Math.min(12, gainValue)); band.gain.setTargetAtTime(clampedGain, currentTime, transitionTime); } } } /** * Enable or disable bass boost * @param enabled - Whether to enable bass boost * @param boostAmount - Boost amount in dB (default: 6) */ setBassBoost(enabled, boostAmount = 6) { const currentTime = this.audioContext.currentTime; const targetGain = enabled ? Math.max(0, Math.min(12, boostAmount)) : 0; this.bassBoostFilter.gain.setTargetAtTime(targetGain, currentTime, 0.05); } /** * Set master volume * @param volume - Volume level (0.0 to 1.0) */ setMasterVolume(volume) { if (!ValidationUtils.isValidNumber(volume)) { throw new Error("Equalizer: Invalid volume value"); } const clampedVolume = Math.max(0, Math.min(1, volume)); const currentTime = this.audioContext.currentTime; this.gainNode.gain.setTargetAtTime(clampedVolume, currentTime, 0.01); } /** * Get current equalizer gains * @returns Array of current gain values */ getCurrentGains() { return this.filterBands.map((band) => band.gain.value); } /** * Reset equalizer to flat response */ reset() { const currentTime = this.audioContext.currentTime; for (const band of this.filterBands) { band.gain.setTargetAtTime(0, currentTime, 0.05); } this.bassBoostFilter.gain.setTargetAtTime(0, currentTime, 0.05); this.gainNode.gain.setTargetAtTime(1, currentTime, 0.05); } /** * Get equalizer status * @returns Current equalizer status */ status() { if (this.audioContext.state === "suspended") { this.audioContext.resume(); } return this.contextStatus; } /** * Update compressor settings * @param settings - Partial compressor settings */ setCompressorSettings(settings) { const currentTime = this.audioContext.currentTime; if (settings.threshold !== void 0) { this.compressorNode.threshold.setTargetAtTime(settings.threshold, currentTime, 0.01); } if (settings.knee !== void 0) { this.compressorNode.knee.setTargetAtTime(settings.knee, currentTime, 0.01); } if (settings.ratio !== void 0) { this.compressorNode.ratio.setTargetAtTime(settings.ratio, currentTime, 0.01); } if (settings.attack !== void 0) { this.compressorNode.attack.setTargetAtTime(settings.attack, currentTime, 0.01); } if (settings.release !== void 0) { this.compressorNode.release.setTargetAtTime(settings.release, currentTime, 0.01); } } /** * Get available presets * @returns Object containing all available presets */ static getPresets() { return EQUALIZER_PRESETS; } /** * Destroy equalizer and cleanup resources */ async destroy() { try { if (this.audioContext && this.audioContext.state !== "closed") { await this.audioContext.close(); } this.contextStatus = "CLOSED"; } catch (error) { console.error("Equalizer: Error during cleanup:", error); } } }; // src/lib/hls/index.ts var HlsManager = class { constructor() { this.hlsInstance = null; this.isHlsSupported = false; this.isLoggingEnabled = false; this.checkHlsSupport(); } /** * Initialize HLS manager * @param enableLogging - Whether to enable HLS logging * @param hlsConfig - HLS configuration options */ async initialize(enableLogging = false, hlsConfig = {}) { this.isLoggingEnabled = enableLogging; if (this.isHlsSupported) { await this.loadHlsLibrary(); await this.createHlsInstance(hlsConfig); } } /** * Check if HLS is supported * @private */ checkHlsSupport() { const audio = document.createElement("audio"); const hasNativeHls = audio.canPlayType("application/vnd.apple.mpegurl") !== ""; this.isHlsSupported = hasNativeHls || typeof window !== "undefined"; } /** * Load hls.js library dynamically * @private */ async loadHlsLibrary() { if (typeof window === "undefined") return; if (window.Hls) { return; } try { const { default: Hls } = await import('hls.js'); window.Hls = Hls; } catch (_error) { console.warn("HlsManager: hls.js not found, HLS streams will not be supported"); this.isHlsSupported = false; } } /** * Create HLS instance * @private */ async createHlsInstance(config) { const Hls = window.Hls; if (!Hls || !Hls.isSupported()) { this.isHlsSupported = false; return; } const defaultConfig = { debug: this.isLoggingEnabled, enableWorker: true, lowLatencyMode: false, ...config }; this.hlsInstance = new Hls(defaultConfig); if (this.isLoggingEnabled) { this.attachHlsEventListeners(); } } /** * Attach HLS event listeners for debugging * @private */ attachHlsEventListeners() { if (!this.hlsInstance) return; this.hlsInstance.on(window.Hls.Events.ERROR, (_event, data) => { if (data.fatal) { console.error("HlsManager: Fatal error:", data); } else { console.warn("HlsManager: Non-fatal error:", data); } }); this.hlsInstance.on(window.Hls.Events.MANIFEST_LOADED, () => { console.log("HlsManager: Manifest loaded successfully"); }); } /** * Load HLS media * @param track - Media track with HLS source */ async loadMedia(track) { if (!this.isHlsSupported || !this.hlsInstance) { throw new Error("HlsManager: HLS not supported or not initialized"); } const audioElement = globalThis.getAudioElement?.(); if (!audioElement) { throw new Error("HlsManager: Audio element not available"); } this.hlsInstance.attachMedia(audioElement); this.hlsInstance.loadSource(track.source); } /** * Get HLS instance * @returns HLS instance or null */ getHlsInstance() { return this.hlsInstance; } /** * Check if HLS is supported * @returns True if HLS is supported */ isSupported() { return this.isHlsSupported; } /** * Destroy HLS instance and cleanup */ async destroy() { if (this.hlsInstance) { this.hlsInstance.destroy(); this.hlsInstance = null; } } }; // src/lib/media-session/index.ts var globalAudioElement; var MediaSessionManager = class { constructor() { this.isSupported = false; this.currentTrack = null; this.checkSupport(); } /** * Initialize Media Session API * @param audioElement - The audio element to control */ async initialize(audioElement) { if (!this.isSupported) { console.warn("MediaSessionManager: Media Session API not supported"); return; } if (audioElement) { globalAudioElement = audioElement; } this.setupActionHandlers(); } /** * Check if Media Session API is supported * @private */ checkSupport() { this.isSupported = "mediaSession" in navigator; } /** * Update media metadata * @param track - Current media track */ updateMetadata(track) { if (!this.isSupported) return; this.currentTrack = track; navigator.mediaSession.metadata = new MediaMetadata({ title: track.title || "Unknown Title", artist: track.artist || "Unknown Artist", album: track.album || "Unknown Album", artwork: track.artwork ? Array.isArray(track.artwork) ? track.artwork.map((art) => ({ src: art.src, sizes: art.sizes || "512x512", type: art.type || "image/png" })) : [ { src: track.artwork.src, sizes: track.artwork.sizes || "512x512", type: track.artwork.type || "image/png" } ] : [] }); } /** * Update playback queue for media session * @param queue - Current playback queue */ updateQueue(queue) { if (!this.isSupported) return; console.debug("MediaSessionManager: Queue updated with", queue.length, "tracks"); } /** * Set playback state * @param state - Playback state ('playing', 'paused', 'none') */ setPlaybackState(state) { if (!this.isSupported) return; navigator.mediaSession.playbackState = state; } /** * Set position state * @param duration - Track duration in seconds * @param position - Current position in seconds * @param playbackRate - Current playback rate */ setPositionState(duration, position, playbackRate = 1) { if (!this.isSupported) return; try { navigator.mediaSession.setPositionState({ duration: duration || 0, position: position || 0, playbackRate: playbackRate || 1 }); } catch (error) { console.warn("MediaSessionManager: Failed to set position state:", error); } } /** * Setup media session action handlers * @private */ setupActionHandlers() { if (!this.isSupported) return; navigator.mediaSession.setActionHandler("play", () => { this.handleAction("play"); }); navigator.mediaSession.setActionHandler("pause", () => { this.handleAction("pause"); }); navigator.mediaSession.setActionHandler("previoustrack", () => { this.handleAction("previoustrack"); }); navigator.mediaSession.setActionHandler("nexttrack", () => { this.handleAction("nexttrack"); }); navigator.mediaSession.setActionHandler("seekbackward", (details) => { this.handleAction("seekbackward", details.seekOffset || 10); }); navigator.mediaSession.setActionHandler("seekforward", (details) => { this.handleAction("seekforward", details.seekOffset || 10); }); navigator.mediaSession.setActionHandler("seekto", (details) => { this.handleAction("seekto", details.seekTime || 0); }); } /** * Handle media session actions * @private */ handleAction(action, value) { const audioElement = globalAudioElement; if (!audioElement) return; const playerInstance = globalThis.audioHeadlessInstance; switch (action) { case "play": audioElement.play().catch(console.error); break; case "pause": audioElement.pause(); break; case "previoustrack": playerInstance?.playPrevious?.(); break; case "nexttrack": playerInstance?.playNext?.(); break; case "seekbackward": if (value && audioElement.currentTime >= value) { audioElement.currentTime -= value; } break; case "seekforward": if (value && audioElement.duration) { audioElement.currentTime = Math.min( audioElement.currentTime + value, audioElement.duration ); } break; case "seekto": if (typeof value === "number" && audioElement.duration) { audioElement.currentTime = Math.min(value, audioElement.duration); } break; } } /** * Clear media session metadata */ clearMetadata() { if (!this.isSupported) return; navigator.mediaSession.metadata = null; this.currentTrack = null; } /** * Check if Media Session is supported * @returns True if supported */ isMediaSessionSupported() { return this.isSupported; } /** * Destroy media session and cleanup */ async destroy() { if (!this.isSupported) return; this.clearMetadata(); this.setPlaybackState("none"); const actions = [ "play", "pause", "previoustrack", "nexttrack", "seekbackward", "seekforward", "seekto" ]; for (const action of actions) { try { navigator.mediaSession.setActionHandler(action, null); } catch { } } } }; // src/core/player.ts var globalAudioElement2; var AudioHeadless = class _AudioHeadless { constructor() { this.isEqualizerEnabled = false; this.shouldShowNotifications = false; this.currentTrackIndex = 0; this.originalPlaybackQueue = []; this.isShuffleEnabled = false; this.currentLoopMode = "OFF"; // Audio processing modules this.equalizerStatus = "IDEAL"; if (_AudioHeadless.playerInstance) { console.warn( "AudioHeadless: Multiple instances detected. Returning existing singleton instance." ); return _AudioHeadless.playerInstance; } if (process.env.NODE_ENV !== PLAYER_CONSTANTS.DEVELOPMENT && globalAudioElement2) { throw new Error("AudioHeadless: Cannot create multiple audio instances in production."); } _AudioHeadless.playerInstance = this; this.audioElement = new Audio(); globalAudioElement2 = this.audioElement; this.initializeCoreModules(); } /** * Initialize core utility modules * @private */ initializeCoreModules() { this.eventEmitter = new EventEmitter(); this.queueManager = new QueueManager(); } /** * Initialize the audio player with configuration * @param config - Player configuration options */ async initialize(config) { const { preloadStrategy = "auto", autoPlay = false, useDefaultEventListeners = true, customEventListeners = null, showNotificationActions = false, enablePlayLog = false, enableHls = false, enableEqualizer = false, crossOrigin = null, hlsConfig = {} } = config; this.audioElement.setAttribute("id", "audioheadless_instance"); this.audioElement.preload = preloadStrategy; this.audioElement.autoplay = autoPlay; this.audioElement.crossOrigin = crossOrigin; this.isPlaybackLoggingEnabled = enablePlayLog; this.isEqualizerEnabled = enableEqualizer; this.shouldShowNotifications = showNotificationActions; await this.initializeEventListeners( useDefaultEventListeners, customEventListeners, enablePlayLog ); if (showNotificationActions) { this.mediaSessionManager = new MediaSessionManager(); await this.mediaSessionManager.initialize(this.audioElement); } if (enableHls) { this.hlsManager = new HlsManager(); await this.hlsManager.initialize(enablePlayLog, hlsConfig); } if (enableEqualizer) { await this.initializeEqualizer(); } } /** * Initialize event listeners for the audio element * @private */ async initializeEventListeners(_useDefault, _customListeners, _enableLogging) { } /** * Initialize the equalizer module * @private */ async initializeEqualizer() { try { this.equalizerInstance = new Equalizer(); this.equalizerInstance.initializeWithAudioElement(this.audioElement); this.equalizerStatus = this.equalizerInstance.status(); } catch (error) { console.error("AudioHeadless: Failed to initialize equalizer:", error); this.equalizerStatus = "CLOSED"; } } // ==================== MEDIA LOADING METHODS ==================== /** * Load a media track into the player * @param track - The media track to load * @param fetchFunction - Optional function to fetch track data */ async loadTrack(track, fetchFunction) { if (!track) { throw new Error("AudioHeadless: No media track provided"); } if (fetchFunction && !track.source.length) { this.mediaFetchFunction = fetchFunction; } this.updateCurrentTrackIndex(track); const isHlsStream = track.source.includes(".m3u8"); if (this.isPlaybackLoggingEnabled) { PlaybackUtils.calculateActualPlayedLength(globalAudioElement2, "TRACK_CHANGE"); } if (isHlsStream && !this.audioElement.canPlayType("application/vnd.apple.mpegurl")) { await this.loadHlsStream(track); } else { this.audioElement.src = track.source; } this.emitStateChange({ playbackState: PLAYBACK_STATES.TRACK_CHANGE, currentTrackPlayTime: 0, currentTrack: track }); if (this.mediaSessionManager) { this.mediaSessionManager.updateMetadata(track); } this.audioElement.load(); } /** * Load and immediately play a media track * @param track - The media track to load and play * @param fetchFunction - Optional function to fetch track data */ async loadAndPlay(track, fetchFunction) { const targetTrack = track || (this.playbackQueue?.length > 0 ? this.playbackQueue[0] : void 0); if (fetchFunction && ValidationUtils.isValidFunction(fetchFunction) && targetTrack?.source.length) { this.mediaFetchFunction = fetchFunction; await fetchFunction(targetTrack); } if (!targetTrack) { throw new Error("AudioHeadless: No media track available for playback"); } await this.loadTrack(targetTrack); if (this.audioElement.readyState >= 4) { setTimeout(async () => { await this.play(); if (this.isEqualizerEnabled) { await this.initializeEqualizer(); } }, 950); } } /** * Load HLS stream using HLS manager * @private */ async loadHlsStream(track) { if (!this.hlsManager) { console.warn( "AudioHeadless: HLS stream detected but HLS support not enabled. Please enable HLS in player configuration." ); await this.reset(); return; } const hlsInstance = this.hlsManager.getHlsInstance(); if (hlsInstance) { hlsInstance.detachMedia(); await this.hlsManager.loadMedia(track); } } // ==================== PLAYBACK CONTROL METHODS ==================== /** * Start audio playback */ async play() { const hasSource = this.audioElement.src !== ""; const isReady = this.audioElement.readyState >= 4; if (this.audioElement.paused && isReady && hasSource) { try { await this.audioElement.play(); console.log("AudioHeadless: Playback started"); } catch (error) { console.warn("AudioHeadless: Playback cancelled or failed:", error); } } if (this.isEqualizerEnabled && this.equalizerStatus === "IDEAL") { await this.initializeEqualizer(); } } /** * Pause audio playback */ pause() { if (this.audioElement && !this.audioElement.paused) { this.audioElement.pause(); } } /** * Stop audio playback and reset position */ stop() { if (this.audioElement && !this.audioElement.paused) { this.audioElement.pause(); this.audioElement.currentTime = 0; } } // ==================== VOLUME AND PLAYBACK RATE METHODS ==================== /** * Set audio volume (0-100) * @param volume - Volume level between 0 and 100 */ setVolume(volume) { if (volume < 0 || volume > 100) { throw new Error("AudioHeadless: Volume must be between 0 and 100"); } const normalizedVolume = volume / 100; this.audioElement.volume = normalizedVolume; this.emitStateChange({ volume }); } /** * Get current volume (0-100) */ getVolume() { return Math.round(this.audioElement.volume * 100); } /** * Set playback rate * @param rate - Playback rate multiplier */ setPlaybackRate(rate) { this.audioElement.playbackRate = rate; this.emitStateChange({ playbackRate: rate }); } /** * Get current playback rate */ getPlaybackRate() { return this.audioElement.playbackRate; } /** * Seek to specific time in seconds * @param seconds - Time position in seconds */ seekToTime(seconds) { if (seconds < 0) { throw new Error("AudioHeadless: Seek time cannot be negative"); } this.audioElement.currentTime = seconds; } /** * Seek by relative time offset * @param seconds - Time offset in seconds (can be negative) */ seekByTime(seconds) { const newTime = this.audioElement.currentTime + seconds; this.seekToTime(Math.max(0, newTime)); } /** * Mute audio */ mute() { this.audioElement.muted = true; } /** * Unmute audio */ unmute() { this.audioElement.muted = false; } /** * Check if audio is muted */ isMuted() { return this.audioElement.muted; } // ==================== QUEUE MANAGEMENT METHODS ==================== /** * Set the playback queue * @param tracks - Array of media tracks * @param playbackType - Type of queue playback (default, reverse, shuffle) */ setQueue(tracks, playbackType = "DEFAULT") { this.clearQueue(); if (!ValidationUtils.isValidArray(tracks)) { console.warn("AudioHeadless: Invalid tracks array provided"); return; } this.queueManager.setQueue(tracks, playbackType); this.playbackQueue = this.queueManager.getQueue(); if (playbackType === "SHUFFLE") { this.isShuffleEnabled = true; this.originalPlaybackQueue = [...tracks]; } this.emitQueueChange(); } /** * Add tracks to the current queue * @param tracks - Single track or array of tracks to add */ addToQueue(tracks) { const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; if (this.playbackQueue) { this.playbackQueue.push(...tracksArray); } else { this.playbackQueue = tracksArray; } } /** * Remove track from queue by ID * @param trackId - ID of the track to remove */ removeFromQueue(trackId) { if (this.playbackQueue) { this.playbackQueue = this.playbackQueue.filter((track) => track.id !== trackId); } } /** * Clear the entire queue */ clearQueue() { this.playbackQueue = []; this.currentTrackIndex = 0; this.originalPlaybackQueue = []; this.isShuffleEnabled = false; } /** * Get current queue */ getQueue() { return this.playbackQueue || []; } /** * Get current track index */ getCurrentTrackIndex() { return this.currentTrackIndex; } /** * Play next track in queue */ async playNext() { const nextIndex = this.currentTrackIndex + 1; if (this.playbackQueue?.length > nextIndex) { const nextTrack = this.playbackQueue[nextIndex]; await this.loadAndPlay(nextTrack, this.mediaFetchFunction); this.currentTrackIndex = nextIndex; } else { this.stop(); this.emitStateChange({ playbackState: PLAYBACK_STATES.QUEUE_ENDED }); } } /** * Play previous track in queue */ async playPrevious() { const previousIndex = this.currentTrackIndex - 1; if (previousIndex >= 0) { const previousTrack = this.playbackQueue[previousIndex]; await this.loadAndPlay(previousTrack, this.mediaFetchFunction); this.currentTrackIndex = previousIndex; } else { console.log("AudioHeadless: Already at the beginning of the queue"); } } /** * Play track at specific index * @param index - Index of track to play */ async playTrackAt(index) { if (index < 0 || index >= this.playbackQueue.length) { throw new Error("AudioHeadless: Track index out of bounds"); } const track = this.playbackQueue[index]; await this.loadAndPlay(track, this.mediaFetchFunction); this.currentTrackIndex = index; } // ==================== SHUFFLE AND LOOP METHODS ==================== /** * Enable or disable shuffle mode * @param enabled - Whether to enable shuffle */ enableShuffle(enabled) { if (enabled && !this.isShuffleEnabled) { this.originalPlaybackQueue = [...this.playbackQueue]; this.queueManager.setQueue(this.playbackQueue, "SHUFFLE"); this.playbackQueue = this.queueManager.getQueue(); this.isShuffleEnabled = true; } else if (!enabled && this.isShuffleEnabled) { this.playbackQueue = [...this.originalPlaybackQueue]; this.isShuffleEnabled = false; } } /** * Check if shuffle is enabled */ isShuffleActive() { return this.isShuffleEnabled; } /** * Set loop mode * @param mode - Loop mode (SINGLE, QUEUE, OFF) */ setLoopMo