Blame Raw
· · · 605 lines (18.8 KB)
0 contributors
1import { formatTime } from './utils.js';
2
3export class AudioPlayer {
4 /**
5 * Creates an audio player instance
6 * @param {HTMLElement} audioElement - The audio element to control
7 * @param {HTMLElement} containerElement - The container element with player controls
8 * @param {Object} options - Configuration options
9 */
10 constructor(audioElement, containerElement, options = {}) {
11 if (!(audioElement instanceof HTMLAudioElement)) {
12 throw new Error('First argument must be an HTMLAudioElement');
13 }
14 if (!(containerElement instanceof HTMLElement)) {
15 throw new Error('Second argument must be an HTMLElement');
16 }
17
18 this.audio = audioElement;
19 this.container = containerElement;
20 this.options = {
21 title: '',
22 autoplay: false,
23 volume: 1.0,
24 onPlay: null,
25 onPause: null,
26 onEnded: null,
27 onTimeUpdate: null,
28 onVolumeChange: null,
29 ...options,
30 };
31
32 this.isDragging = false;
33 this.isDestroyed = false;
34
35 // Store bound event handlers for cleanup
36 this.boundHandlers = {
37 // Audio event handlers
38 audioLoadedMetadata: this.onAudioLoadedMetadata.bind(this),
39 audioTimeUpdate: this.onAudioTimeUpdate.bind(this),
40 audioPlay: this.onAudioPlay.bind(this),
41 audioPause: this.onAudioPause.bind(this),
42 audioEnded: this.onAudioEnded.bind(this),
43 audioVolumeChange: this.onAudioVolumeChange.bind(this),
44 // Control event handlers
45 playPauseClick: this.onPlayPauseClick.bind(this),
46 volumeClick: this.onVolumeClick.bind(this),
47 volumeInput: this.onVolumeInput.bind(this),
48 progressMouseDown: this.onProgressMouseDown.bind(this),
49 progressClick: this.onProgressClick.bind(this),
50 progressMouseMove: this.onProgressMouseMove.bind(this),
51 documentMouseMove: this.onDocumentMouseMove.bind(this),
52 documentMouseUp: this.onDocumentMouseUp.bind(this),
53 };
54
55 // MutationObserver for DOM cleanup detection
56 this.observer = null;
57
58 this.init();
59 }
60
61 /**
62 * Initializes the audio player by setting up elements, events, and initial state
63 * @returns {void}
64 */
65 init() {
66 this.findElements();
67 this.bindEvents();
68 this.setupDOMObserver();
69
70 if (this.options.title) {
71 this.setTitle(this.options.title);
72 } else if (this.audio.title) {
73 this.setTitle(this.audio.title);
74 } else if (this.audio.src) {
75 const srcParts = this.audio.src.split('/');
76 this.setTitle(decodeURIComponent(srcParts[srcParts.length - 1]));
77 }
78
79 if (this.options.autoplay) {
80 this.play();
81 }
82
83 this.setVolume(this.options.volume);
84
85 // Initialize time displays
86 this.updateTimeDisplays();
87 }
88
89 /**
90 * Finds and caches all required DOM elements within the container
91 * @returns {void}
92 */
93 findElements() {
94 this.elements = {
95 title: this.container.querySelector('.audio-player-title'),
96 playPauseBtn: this.container.querySelector('.audio-player-play-pause'),
97 currentTime: this.container.querySelector('.audio-player-current-time'),
98 totalTime: this.container.querySelector('.audio-player-total-time'),
99 progress: this.container.querySelector('.audio-player-progress'),
100 progressBar: this.container.querySelector('.audio-player-progress-bar'),
101 volumeBtn: this.container.querySelector('.audio-player-volume'),
102 };
103
104 // Validate required elements
105 const requiredElements = ['playPauseBtn', 'currentTime', 'totalTime', 'progress', 'progressBar', 'volumeBtn'];
106 for (const key of requiredElements) {
107 if (!this.elements[key]) {
108 console.warn(`AudioPlayer: Required element .audio-player-${key.replace(/([A-Z])/g, '-$1').toLowerCase()} not found`);
109 }
110 }
111 }
112
113 /**
114 * Sets up a MutationObserver to detect when audio or container elements are removed from DOM
115 * @returns {void}
116 */
117 setupDOMObserver() {
118 // Watch for removal of audio or container from DOM
119 this.observer = new MutationObserver((mutations) => {
120 for (const mutation of mutations) {
121 for (const node of mutation.removedNodes) {
122 if (node === this.audio || node === this.container ||
123 node.contains?.(this.audio) || node.contains?.(this.container)) {
124 this.destroy();
125 return;
126 }
127 }
128 }
129 });
130
131 // Observe the parent nodes
132 const chatParent = this.audio.closest('#chat') ?? document.body;
133
134 if (chatParent) {
135 this.observer.observe(chatParent, { childList: true, subtree: true });
136 }
137 }
138
139 /**
140 * Binds all event listeners to audio and control elements
141 * @returns {void}
142 */
143 bindEvents() {
144 // Audio events
145 this.audio.addEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata);
146 this.audio.addEventListener('timeupdate', this.boundHandlers.audioTimeUpdate);
147 this.audio.addEventListener('play', this.boundHandlers.audioPlay);
148 this.audio.addEventListener('pause', this.boundHandlers.audioPause);
149 this.audio.addEventListener('ended', this.boundHandlers.audioEnded);
150 this.audio.addEventListener('volumechange', this.boundHandlers.audioVolumeChange);
151
152 // Control events
153 if (this.elements.playPauseBtn) {
154 this.elements.playPauseBtn.addEventListener('click', this.boundHandlers.playPauseClick);
155 }
156 if (this.elements.volumeBtn) {
157 this.elements.volumeBtn.addEventListener('click', this.boundHandlers.volumeClick);
158 }
159 if (this.elements.progress) {
160 this.elements.progress.addEventListener('mousedown', this.boundHandlers.progressMouseDown);
161 this.elements.progress.addEventListener('click', this.boundHandlers.progressClick);
162 this.elements.progress.addEventListener('mousemove', this.boundHandlers.progressMouseMove);
163 }
164 }
165
166 /**
167 * Removes all event listeners from audio and control elements
168 * @returns {void}
169 */
170 unbindEvents() {
171 // Audio events
172 this.audio.removeEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata);
173 this.audio.removeEventListener('timeupdate', this.boundHandlers.audioTimeUpdate);
174 this.audio.removeEventListener('play', this.boundHandlers.audioPlay);
175 this.audio.removeEventListener('pause', this.boundHandlers.audioPause);
176 this.audio.removeEventListener('ended', this.boundHandlers.audioEnded);
177 this.audio.removeEventListener('volumechange', this.boundHandlers.audioVolumeChange);
178
179 // Control events
180 if (this.elements.playPauseBtn) {
181 this.elements.playPauseBtn.removeEventListener('click', this.boundHandlers.playPauseClick);
182 }
183 if (this.elements.volumeBtn) {
184 this.elements.volumeBtn.removeEventListener('click', this.boundHandlers.volumeClick);
185 }
186 if (this.elements.progress) {
187 this.elements.progress.removeEventListener('mousedown', this.boundHandlers.progressMouseDown);
188 this.elements.progress.removeEventListener('click', this.boundHandlers.progressClick);
189 this.elements.progress.removeEventListener('mousemove', this.boundHandlers.progressMouseMove);
190 }
191
192 // Document events
193 document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove);
194 document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp);
195 }
196
197 // Audio event handlers
198 /**
199 * Handles the audio element's loadedmetadata event
200 * @returns {void}
201 */
202 onAudioLoadedMetadata() {
203 if (this.isDestroyed) return;
204 this.updateTimeDisplays();
205 }
206
207 /**
208 * Handles the audio element's timeupdate event
209 * @returns {void}
210 */
211 onAudioTimeUpdate() {
212 if (this.isDestroyed || this.isDragging) return;
213
214 const percent = (this.audio.currentTime / this.audio.duration) * 100 || 0;
215 if (this.elements.progressBar) {
216 /** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%';
217 }
218 if (this.elements.currentTime) {
219 this.elements.currentTime.textContent = formatTime(this.audio.currentTime);
220 }
221
222 if (typeof this.options.onTimeUpdate === 'function') {
223 this.options.onTimeUpdate.call(this, this.audio.currentTime, this.audio.duration);
224 }
225 }
226
227 /**
228 * Handles the audio element's play event
229 * @returns {void}
230 */
231 onAudioPlay() {
232 if (this.isDestroyed) return;
233
234 if (this.elements.playPauseBtn) {
235 this.elements.playPauseBtn.classList.remove('fa-play');
236 this.elements.playPauseBtn.classList.add('fa-pause');
237 this.elements.playPauseBtn.setAttribute('title', 'Pause');
238 }
239
240 if (typeof this.options.onPlay === 'function') {
241 this.options.onPlay.call(this);
242 }
243 }
244
245 /**
246 * Handles the audio element's pause event
247 * @returns {void}
248 */
249 onAudioPause() {
250 if (this.isDestroyed) return;
251
252 if (this.elements.playPauseBtn) {
253 this.elements.playPauseBtn.classList.remove('fa-pause');
254 this.elements.playPauseBtn.classList.add('fa-play');
255 this.elements.playPauseBtn.setAttribute('title', 'Play');
256 }
257
258 if (typeof this.options.onPause === 'function') {
259 this.options.onPause.call(this);
260 }
261 }
262
263 /**
264 * Handles the audio element's ended event
265 * @returns {void}
266 */
267 onAudioEnded() {
268 if (this.isDestroyed) return;
269
270 if (this.elements.playPauseBtn) {
271 this.elements.playPauseBtn.classList.remove('fa-pause');
272 this.elements.playPauseBtn.classList.add('fa-play');
273 this.elements.playPauseBtn.setAttribute('title', 'Play');
274 }
275
276 if (typeof this.options.onEnded === 'function') {
277 this.options.onEnded.call(this);
278 }
279 }
280
281 /**
282 * Handles the audio element's volumechange event
283 * @returns {void}
284 */
285 onAudioVolumeChange() {
286 if (this.isDestroyed) return;
287
288 this.updateVolumeIcon();
289
290 if (typeof this.options.onVolumeChange === 'function') {
291 this.options.onVolumeChange.call(this, this.audio.volume, this.audio.muted);
292 }
293 }
294
295 // Control event handlers
296 /**
297 * Handles click events on the play/pause button
298 * @param {MouseEvent} e - The click event
299 * @returns {void}
300 */
301 onPlayPauseClick(e) {
302 e.preventDefault();
303 this.togglePlay();
304 }
305
306 /**
307 * Handles click events on the volume button
308 * @param {MouseEvent} e - The click event
309 * @returns {void}
310 */
311 onVolumeClick(e) {
312 e.preventDefault();
313 this.toggleMute();
314 }
315
316 /**
317 * Handles input events on the volume slider
318 * @param {InputEvent} e - The input event
319 * @returns {void}
320 */
321 onVolumeInput(e) {
322 if (!(e.target instanceof HTMLInputElement)) return;
323 const value = parseFloat(e.target.value);
324 this.setVolume(value);
325 }
326
327 /**
328 * Handles mousedown events on the progress bar
329 * @param {MouseEvent} e - The mousedown event
330 * @returns {void}
331 */
332 onProgressMouseDown(e) {
333 this.isDragging = true;
334 this.updateProgress(e);
335 document.addEventListener('mousemove', this.boundHandlers.documentMouseMove);
336 document.addEventListener('mouseup', this.boundHandlers.documentMouseUp);
337 }
338
339 /**
340 * Handles click events on the progress bar
341 * @param {MouseEvent} e - The click event
342 * @returns {void}
343 */
344 onProgressClick(e) {
345 if (!this.isDragging) {
346 this.updateProgress(e);
347 }
348 }
349
350 /**
351 * Handles mousemove on the progress bar (no-op if dragging)
352 * @param {MouseEvent} e - The mousemove event
353 * @returns {void}
354 */
355 onProgressMouseMove(e) {
356 if (!this.isDragging) {
357 this.updateProgressTitle(e);
358 }
359 }
360
361 /**
362 * Handles document mousemove events during progress bar dragging
363 * @param {MouseEvent} e - The mousemove event
364 * @returns {void}
365 */
366 onDocumentMouseMove(e) {
367 if (this.isDragging) {
368 this.updateProgress(e);
369 }
370 }
371
372 /**
373 * Handles document mouseup events to end progress bar dragging
374 * @returns {void}
375 */
376 onDocumentMouseUp() {
377 if (this.isDragging) {
378 this.isDragging = false;
379 document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove);
380 document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp);
381 }
382 }
383
384 /**
385 * Updates the progress bar position and seeks audio based on mouse position
386 * @param {MouseEvent} e - The mouse event containing position information
387 * @returns {void}
388 */
389 updateProgress(e) {
390 if (!this.elements.progress) return;
391
392 const rect = this.elements.progress.getBoundingClientRect();
393 const offsetX = e.clientX - rect.left;
394 const width = rect.width;
395 const percent = Math.max(0, Math.min(100, (offsetX / width) * 100));
396
397 if (this.elements.progressBar) {
398 /** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%';
399 }
400
401 const seekTime = (percent / 100) * this.audio.duration;
402 if (isFinite(seekTime)) {
403 this.audio.currentTime = seekTime;
404 if (this.elements.currentTime) {
405 this.elements.currentTime.textContent = formatTime(seekTime);
406 }
407 }
408 }
409
410 /**
411 * Updates the volume icon based on current volume and mute state
412 * @returns {void}
413 */
414 updateVolumeIcon() {
415 if (!this.elements.volumeBtn) return;
416
417 const volume = this.audio.volume;
418 const isMuted = this.audio.muted;
419
420 this.elements.volumeBtn.classList.remove('fa-volume-high', 'fa-volume-low', 'fa-volume-off', 'fa-volume-xmark');
421
422 if (isMuted || volume === 0) {
423 this.elements.volumeBtn.classList.add('fa-volume-xmark');
424 } else if (volume < 0.5) {
425 this.elements.volumeBtn.classList.add('fa-volume-low');
426 } else {
427 this.elements.volumeBtn.classList.add('fa-volume-high');
428 }
429 }
430
431 /**
432 * Updates the current time and total time display elements
433 * @returns {void}
434 */
435 updateTimeDisplays() {
436 if (this.elements.currentTime) {
437 this.elements.currentTime.textContent = formatTime(this.audio.currentTime || 0);
438 }
439 if (this.elements.totalTime) {
440 this.elements.totalTime.textContent = formatTime(this.audio.duration || 0);
441 }
442 }
443
444 /**
445 * Updates the mouseover title on the progress bar to show time at cursor position
446 * @param {MouseEvent} e - The mouse event
447 * @returns {void}
448 */
449 updateProgressTitle(e) {
450 if (!this.elements.progress) return;
451
452 const rect = this.elements.progress.getBoundingClientRect();
453 const offsetX = e.clientX - rect.left;
454 const width = rect.width;
455 const percent = Math.max(0, Math.min(100, (offsetX / width) * 100));
456
457 this.elements.progress.setAttribute('title', formatTime((percent / 100) * this.audio.duration));
458 }
459
460 // Public methods
461 /**
462 * Starts audio playback
463 * @returns {void}
464 */
465 play() {
466 if (this.isDestroyed) return;
467 if (this.audio.paused) {
468 const playPromise = this.audio.play();
469 if (playPromise !== undefined) {
470 playPromise.catch(error => {
471 console.error('Audio play failed:', error);
472 });
473 }
474 }
475 }
476
477 /**
478 * Pauses audio playback
479 * @returns {void}
480 */
481 pause() {
482 if (this.isDestroyed) return;
483 if (!this.audio.paused) {
484 this.audio.pause();
485 }
486 }
487
488 /**
489 * Toggles between play and pause states
490 * @returns {void}
491 */
492 togglePlay() {
493 if (this.audio.paused) {
494 this.play();
495 } else {
496 this.pause();
497 }
498 }
499
500 /**
501 * Seeks to a specific time in the audio
502 * @param {number} time - The time in seconds to seek to
503 * @returns {void}
504 */
505 seek(time) {
506 if (this.isDestroyed) return;
507 if (isFinite(time) && time >= 0 && time <= this.audio.duration) {
508 this.audio.currentTime = time;
509 }
510 }
511
512 /**
513 * Sets the volume level
514 * @param {number} volume - Volume level between 0.0 and 1.0
515 * @returns {void}
516 */
517 setVolume(volume) {
518 if (this.isDestroyed) return;
519 volume = Math.max(0, Math.min(1, volume));
520 this.audio.volume = volume;
521
522 if (volume > 0 && this.audio.muted) {
523 this.audio.muted = false;
524 }
525 }
526
527 /**
528 * Mutes the audio
529 * @returns {void}
530 */
531 mute() {
532 if (this.isDestroyed) return;
533 this.audio.muted = true;
534 }
535
536 /**
537 * Unmutes the audio
538 * @returns {void}
539 */
540 unmute() {
541 if (this.isDestroyed) return;
542 this.audio.muted = false;
543 }
544
545 /**
546 * Toggles the mute state
547 * @returns {void}
548 */
549 toggleMute() {
550 if (this.isDestroyed) return;
551 this.audio.muted = !this.audio.muted;
552 }
553
554 /**
555 * Sets the audio source URL
556 * @param {string} src - The URL of the audio file
557 * @returns {void}
558 */
559 setSrc(src) {
560 if (this.isDestroyed) return;
561 this.audio.src = src;
562 }
563
564 /**
565 * Sets the title displayed in the player
566 * @param {string} title - The title text to display
567 * @returns {void}
568 */
569 setTitle(title) {
570 if (this.isDestroyed) return;
571 this.options.title = title;
572 if (this.elements.title) {
573 this.elements.title.textContent = title;
574 }
575 }
576
577 /**
578 * Cleans up the player by removing event listeners and clearing references
579 * @returns {void}
580 */
581 destroy() {
582 if (this.isDestroyed) return;
583 this.isDestroyed = true;
584
585 // Stop observing DOM changes
586 if (this.observer) {
587 this.observer.disconnect();
588 this.observer = null;
589 }
590
591 // Pause and clear audio
592 this.pause();
593 this.audio.src = '';
594
595 // Remove all event listeners
596 this.unbindEvents();
597
598 // Clear references to prevent memory leaks
599 this.audio = null;
600 this.container = null;
601 this.elements = null;
602 this.options = null;
603 this.boundHandlers = null;
604 }
605}