import { formatTime } from './utils.js'; export class AudioPlayer { /** * Creates an audio player instance * @param {HTMLElement} audioElement - The audio element to control * @param {HTMLElement} containerElement - The container element with player controls * @param {Object} options - Configuration options */ constructor(audioElement, containerElement, options = {}) { if (!(audioElement instanceof HTMLAudioElement)) { throw new Error('First argument must be an HTMLAudioElement'); } if (!(containerElement instanceof HTMLElement)) { throw new Error('Second argument must be an HTMLElement'); } this.audio = audioElement; this.container = containerElement; this.options = { title: '', autoplay: false, volume: 1.0, onPlay: null, onPause: null, onEnded: null, onTimeUpdate: null, onVolumeChange: null, ...options, }; this.isDragging = false; this.isDestroyed = false; // Store bound event handlers for cleanup this.boundHandlers = { // Audio event handlers audioLoadedMetadata: this.onAudioLoadedMetadata.bind(this), audioTimeUpdate: this.onAudioTimeUpdate.bind(this), audioPlay: this.onAudioPlay.bind(this), audioPause: this.onAudioPause.bind(this), audioEnded: this.onAudioEnded.bind(this), audioVolumeChange: this.onAudioVolumeChange.bind(this), // Control event handlers playPauseClick: this.onPlayPauseClick.bind(this), volumeClick: this.onVolumeClick.bind(this), volumeInput: this.onVolumeInput.bind(this), progressMouseDown: this.onProgressMouseDown.bind(this), progressClick: this.onProgressClick.bind(this), progressMouseMove: this.onProgressMouseMove.bind(this), documentMouseMove: this.onDocumentMouseMove.bind(this), documentMouseUp: this.onDocumentMouseUp.bind(this), }; // MutationObserver for DOM cleanup detection this.observer = null; this.init(); } /** * Initializes the audio player by setting up elements, events, and initial state * @returns {void} */ init() { this.findElements(); this.bindEvents(); this.setupDOMObserver(); if (this.options.title) { this.setTitle(this.options.title); } else if (this.audio.title) { this.setTitle(this.audio.title); } else if (this.audio.src) { const srcParts = this.audio.src.split('/'); this.setTitle(decodeURIComponent(srcParts[srcParts.length - 1])); } if (this.options.autoplay) { this.play(); } this.setVolume(this.options.volume); // Initialize time displays this.updateTimeDisplays(); } /** * Finds and caches all required DOM elements within the container * @returns {void} */ findElements() { this.elements = { title: this.container.querySelector('.audio-player-title'), playPauseBtn: this.container.querySelector('.audio-player-play-pause'), currentTime: this.container.querySelector('.audio-player-current-time'), totalTime: this.container.querySelector('.audio-player-total-time'), progress: this.container.querySelector('.audio-player-progress'), progressBar: this.container.querySelector('.audio-player-progress-bar'), volumeBtn: this.container.querySelector('.audio-player-volume'), }; // Validate required elements const requiredElements = ['playPauseBtn', 'currentTime', 'totalTime', 'progress', 'progressBar', 'volumeBtn']; for (const key of requiredElements) { if (!this.elements[key]) { console.warn(`AudioPlayer: Required element .audio-player-${key.replace(/([A-Z])/g, '-$1').toLowerCase()} not found`); } } } /** * Sets up a MutationObserver to detect when audio or container elements are removed from DOM * @returns {void} */ setupDOMObserver() { // Watch for removal of audio or container from DOM this.observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.removedNodes) { if (node === this.audio || node === this.container || node.contains?.(this.audio) || node.contains?.(this.container)) { this.destroy(); return; } } } }); // Observe the parent nodes const chatParent = this.audio.closest('#chat') ?? document.body; if (chatParent) { this.observer.observe(chatParent, { childList: true, subtree: true }); } } /** * Binds all event listeners to audio and control elements * @returns {void} */ bindEvents() { // Audio events this.audio.addEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata); this.audio.addEventListener('timeupdate', this.boundHandlers.audioTimeUpdate); this.audio.addEventListener('play', this.boundHandlers.audioPlay); this.audio.addEventListener('pause', this.boundHandlers.audioPause); this.audio.addEventListener('ended', this.boundHandlers.audioEnded); this.audio.addEventListener('volumechange', this.boundHandlers.audioVolumeChange); // Control events if (this.elements.playPauseBtn) { this.elements.playPauseBtn.addEventListener('click', this.boundHandlers.playPauseClick); } if (this.elements.volumeBtn) { this.elements.volumeBtn.addEventListener('click', this.boundHandlers.volumeClick); } if (this.elements.progress) { this.elements.progress.addEventListener('mousedown', this.boundHandlers.progressMouseDown); this.elements.progress.addEventListener('click', this.boundHandlers.progressClick); this.elements.progress.addEventListener('mousemove', this.boundHandlers.progressMouseMove); } } /** * Removes all event listeners from audio and control elements * @returns {void} */ unbindEvents() { // Audio events this.audio.removeEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata); this.audio.removeEventListener('timeupdate', this.boundHandlers.audioTimeUpdate); this.audio.removeEventListener('play', this.boundHandlers.audioPlay); this.audio.removeEventListener('pause', this.boundHandlers.audioPause); this.audio.removeEventListener('ended', this.boundHandlers.audioEnded); this.audio.removeEventListener('volumechange', this.boundHandlers.audioVolumeChange); // Control events if (this.elements.playPauseBtn) { this.elements.playPauseBtn.removeEventListener('click', this.boundHandlers.playPauseClick); } if (this.elements.volumeBtn) { this.elements.volumeBtn.removeEventListener('click', this.boundHandlers.volumeClick); } if (this.elements.progress) { this.elements.progress.removeEventListener('mousedown', this.boundHandlers.progressMouseDown); this.elements.progress.removeEventListener('click', this.boundHandlers.progressClick); this.elements.progress.removeEventListener('mousemove', this.boundHandlers.progressMouseMove); } // Document events document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove); document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp); } // Audio event handlers /** * Handles the audio element's loadedmetadata event * @returns {void} */ onAudioLoadedMetadata() { if (this.isDestroyed) return; this.updateTimeDisplays(); } /** * Handles the audio element's timeupdate event * @returns {void} */ onAudioTimeUpdate() { if (this.isDestroyed || this.isDragging) return; const percent = (this.audio.currentTime / this.audio.duration) * 100 || 0; if (this.elements.progressBar) { /** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%'; } if (this.elements.currentTime) { this.elements.currentTime.textContent = formatTime(this.audio.currentTime); } if (typeof this.options.onTimeUpdate === 'function') { this.options.onTimeUpdate.call(this, this.audio.currentTime, this.audio.duration); } } /** * Handles the audio element's play event * @returns {void} */ onAudioPlay() { if (this.isDestroyed) return; if (this.elements.playPauseBtn) { this.elements.playPauseBtn.classList.remove('fa-play'); this.elements.playPauseBtn.classList.add('fa-pause'); this.elements.playPauseBtn.setAttribute('title', 'Pause'); } if (typeof this.options.onPlay === 'function') { this.options.onPlay.call(this); } } /** * Handles the audio element's pause event * @returns {void} */ onAudioPause() { if (this.isDestroyed) return; if (this.elements.playPauseBtn) { this.elements.playPauseBtn.classList.remove('fa-pause'); this.elements.playPauseBtn.classList.add('fa-play'); this.elements.playPauseBtn.setAttribute('title', 'Play'); } if (typeof this.options.onPause === 'function') { this.options.onPause.call(this); } } /** * Handles the audio element's ended event * @returns {void} */ onAudioEnded() { if (this.isDestroyed) return; if (this.elements.playPauseBtn) { this.elements.playPauseBtn.classList.remove('fa-pause'); this.elements.playPauseBtn.classList.add('fa-play'); this.elements.playPauseBtn.setAttribute('title', 'Play'); } if (typeof this.options.onEnded === 'function') { this.options.onEnded.call(this); } } /** * Handles the audio element's volumechange event * @returns {void} */ onAudioVolumeChange() { if (this.isDestroyed) return; this.updateVolumeIcon(); if (typeof this.options.onVolumeChange === 'function') { this.options.onVolumeChange.call(this, this.audio.volume, this.audio.muted); } } // Control event handlers /** * Handles click events on the play/pause button * @param {MouseEvent} e - The click event * @returns {void} */ onPlayPauseClick(e) { e.preventDefault(); this.togglePlay(); } /** * Handles click events on the volume button * @param {MouseEvent} e - The click event * @returns {void} */ onVolumeClick(e) { e.preventDefault(); this.toggleMute(); } /** * Handles input events on the volume slider * @param {InputEvent} e - The input event * @returns {void} */ onVolumeInput(e) { if (!(e.target instanceof HTMLInputElement)) return; const value = parseFloat(e.target.value); this.setVolume(value); } /** * Handles mousedown events on the progress bar * @param {MouseEvent} e - The mousedown event * @returns {void} */ onProgressMouseDown(e) { this.isDragging = true; this.updateProgress(e); document.addEventListener('mousemove', this.boundHandlers.documentMouseMove); document.addEventListener('mouseup', this.boundHandlers.documentMouseUp); } /** * Handles click events on the progress bar * @param {MouseEvent} e - The click event * @returns {void} */ onProgressClick(e) { if (!this.isDragging) { this.updateProgress(e); } } /** * Handles mousemove on the progress bar (no-op if dragging) * @param {MouseEvent} e - The mousemove event * @returns {void} */ onProgressMouseMove(e) { if (!this.isDragging) { this.updateProgressTitle(e); } } /** * Handles document mousemove events during progress bar dragging * @param {MouseEvent} e - The mousemove event * @returns {void} */ onDocumentMouseMove(e) { if (this.isDragging) { this.updateProgress(e); } } /** * Handles document mouseup events to end progress bar dragging * @returns {void} */ onDocumentMouseUp() { if (this.isDragging) { this.isDragging = false; document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove); document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp); } } /** * Updates the progress bar position and seeks audio based on mouse position * @param {MouseEvent} e - The mouse event containing position information * @returns {void} */ updateProgress(e) { if (!this.elements.progress) return; const rect = this.elements.progress.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const width = rect.width; const percent = Math.max(0, Math.min(100, (offsetX / width) * 100)); if (this.elements.progressBar) { /** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%'; } const seekTime = (percent / 100) * this.audio.duration; if (isFinite(seekTime)) { this.audio.currentTime = seekTime; if (this.elements.currentTime) { this.elements.currentTime.textContent = formatTime(seekTime); } } } /** * Updates the volume icon based on current volume and mute state * @returns {void} */ updateVolumeIcon() { if (!this.elements.volumeBtn) return; const volume = this.audio.volume; const isMuted = this.audio.muted; this.elements.volumeBtn.classList.remove('fa-volume-high', 'fa-volume-low', 'fa-volume-off', 'fa-volume-xmark'); if (isMuted || volume === 0) { this.elements.volumeBtn.classList.add('fa-volume-xmark'); } else if (volume < 0.5) { this.elements.volumeBtn.classList.add('fa-volume-low'); } else { this.elements.volumeBtn.classList.add('fa-volume-high'); } } /** * Updates the current time and total time display elements * @returns {void} */ updateTimeDisplays() { if (this.elements.currentTime) { this.elements.currentTime.textContent = formatTime(this.audio.currentTime || 0); } if (this.elements.totalTime) { this.elements.totalTime.textContent = formatTime(this.audio.duration || 0); } } /** * Updates the mouseover title on the progress bar to show time at cursor position * @param {MouseEvent} e - The mouse event * @returns {void} */ updateProgressTitle(e) { if (!this.elements.progress) return; const rect = this.elements.progress.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const width = rect.width; const percent = Math.max(0, Math.min(100, (offsetX / width) * 100)); this.elements.progress.setAttribute('title', formatTime((percent / 100) * this.audio.duration)); } // Public methods /** * Starts audio playback * @returns {void} */ play() { if (this.isDestroyed) return; if (this.audio.paused) { const playPromise = this.audio.play(); if (playPromise !== undefined) { playPromise.catch(error => { console.error('Audio play failed:', error); }); } } } /** * Pauses audio playback * @returns {void} */ pause() { if (this.isDestroyed) return; if (!this.audio.paused) { this.audio.pause(); } } /** * Toggles between play and pause states * @returns {void} */ togglePlay() { if (this.audio.paused) { this.play(); } else { this.pause(); } } /** * Seeks to a specific time in the audio * @param {number} time - The time in seconds to seek to * @returns {void} */ seek(time) { if (this.isDestroyed) return; if (isFinite(time) && time >= 0 && time <= this.audio.duration) { this.audio.currentTime = time; } } /** * Sets the volume level * @param {number} volume - Volume level between 0.0 and 1.0 * @returns {void} */ setVolume(volume) { if (this.isDestroyed) return; volume = Math.max(0, Math.min(1, volume)); this.audio.volume = volume; if (volume > 0 && this.audio.muted) { this.audio.muted = false; } } /** * Mutes the audio * @returns {void} */ mute() { if (this.isDestroyed) return; this.audio.muted = true; } /** * Unmutes the audio * @returns {void} */ unmute() { if (this.isDestroyed) return; this.audio.muted = false; } /** * Toggles the mute state * @returns {void} */ toggleMute() { if (this.isDestroyed) return; this.audio.muted = !this.audio.muted; } /** * Sets the audio source URL * @param {string} src - The URL of the audio file * @returns {void} */ setSrc(src) { if (this.isDestroyed) return; this.audio.src = src; } /** * Sets the title displayed in the player * @param {string} title - The title text to display * @returns {void} */ setTitle(title) { if (this.isDestroyed) return; this.options.title = title; if (this.elements.title) { this.elements.title.textContent = title; } } /** * Cleans up the player by removing event listeners and clearing references * @returns {void} */ destroy() { if (this.isDestroyed) return; this.isDestroyed = true; // Stop observing DOM changes if (this.observer) { this.observer.disconnect(); this.observer = null; } // Pause and clear audio this.pause(); this.audio.src = ''; // Remove all event listeners this.unbindEvents(); // Clear references to prevent memory leaks this.audio = null; this.container = null; this.elements = null; this.options = null; this.boundHandlers = null; } }