Files

637 lines
21 KiB
JavaScript

/**
* Audio Player Module
* A shared audio player implementation based on the working "Your Stream" player
*/
import { globalAudioManager } from './global-audio-manager.js';
export class AudioPlayer {
constructor() {
// Audio state
this.audioElement = null;
this.currentUid = null;
this.isPlaying = false;
this.currentButton = null;
this.audioUrl = '';
this.lastPlayTime = 0;
this.isLoading = false;
this.loadTimeout = null; // For tracking loading timeouts
this.retryCount = 0;
this.maxRetries = 3;
this.retryDelay = 3000; // 3 seconds
this.buffering = false;
this.bufferRetryTimeout = null;
this.lastLoadTime = 0;
this.minLoadInterval = 2000; // 2 seconds between loads
this.pendingLoad = false;
// Create a single audio element that we'll reuse
this.audioElement = new Audio();
this.audioElement.preload = 'none';
this.audioElement.crossOrigin = 'anonymous';
// Bind methods
this.loadAndPlay = this.loadAndPlay.bind(this);
this.stop = this.stop.bind(this);
this.cleanup = this.cleanup.bind(this);
this.handlePlayError = this.handlePlayError.bind(this);
this.handleStalled = this.handleStalled.bind(this);
this.handleWaiting = this.handleWaiting.bind(this);
this.handlePlaying = this.handlePlaying.bind(this);
this.handleEnded = this.handleEnded.bind(this);
// Set up event listeners
this.setupEventListeners();
// Register with global audio manager to handle stop requests from other players
globalAudioManager.addListener('personal', () => {
console.log('[audio-player] Received stop request from global audio manager');
this.stop();
});
}
/**
* Load and play audio for a specific UID
* @param {string} uid - The user ID for the audio stream
* @param {HTMLElement} button - The play/pause button element
*/
/**
* Validates that a UID is in the correct UUID format
* @param {string} uid - The UID to validate
* @returns {boolean} True if valid, false otherwise
*/
isValidUuid(uid) {
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(uid);
}
/**
* Logs an error and updates the button state
* @param {HTMLElement} button - The button to update
* @param {string} message - Error message to log
*/
handleError(button, message) {
console.error(message);
if (button) {
this.updateButtonState(button, 'error');
}
}
async loadAndPlay(uid, button) {
const now = Date.now();
// Prevent rapid successive load attempts
if (this.pendingLoad || (now - this.lastLoadTime < this.minLoadInterval)) {
console.log('[AudioPlayer] Skipping duplicate load request');
return;
}
// Validate UID exists and is in correct format
if (!uid) {
this.handleError(button, 'No UID provided for audio playback');
return;
}
// For logging purposes
const requestId = Math.random().toString(36).substr(2, 8);
console.log(`[AudioPlayer] Load request ${requestId} for UID: ${uid}`);
this.pendingLoad = true;
this.lastLoadTime = now;
// If we're in the middle of loading, check if it's for the same UID
if (this.isLoading) {
// If same UID, ignore duplicate request
if (this.currentUid === uid) {
console.log(`[AudioPlayer] Already loading this UID, ignoring duplicate request: ${uid}`);
this.pendingLoad = false;
return;
}
// If different UID, queue the new request
console.log(`[AudioPlayer] Already loading, queuing request for UID: ${uid}`);
setTimeout(() => {
this.pendingLoad = false;
this.loadAndPlay(uid, button);
}, 500);
return;
}
// If we're in the middle of loading, check if it's for the same UID
if (this.isLoading) {
// If same UID, ignore duplicate request
if (this.currentUid === uid) {
console.log('Already loading this UID, ignoring duplicate request:', uid);
return;
}
// If different UID, queue the new request
console.log('Already loading, queuing request for UID:', uid);
setTimeout(() => this.loadAndPlay(uid, button), 500);
return;
}
// If already playing this stream, just toggle pause/play
if (this.currentUid === uid && this.audioElement) {
try {
if (this.isPlaying) {
console.log('Pausing current playback');
try {
this.audioElement.pause();
this.lastPlayTime = this.audioElement.currentTime;
this.isPlaying = false;
this.updateButtonState(button, 'paused');
} catch (pauseError) {
console.warn('Error pausing audio, continuing with state update:', pauseError);
this.isPlaying = false;
this.updateButtonState(button, 'paused');
}
} else {
console.log('Resuming playback from time:', this.lastPlayTime);
try {
// If we have a last play time, seek to it
if (this.lastPlayTime > 0) {
this.audioElement.currentTime = this.lastPlayTime;
}
await this.audioElement.play();
this.isPlaying = true;
this.updateButtonState(button, 'playing');
} catch (playError) {
console.error('Error resuming playback, reloading source:', playError);
// If resume fails, try reloading the source
this.currentUid = null; // Force reload of the source
return this.loadAndPlay(uid, button);
}
}
return; // Exit after handling pause/resume
} catch (error) {
console.error('Error toggling playback:', error);
this.updateButtonState(button, 'error');
return;
}
}
// If we get here, we're loading a new stream
this.isLoading = true;
this.currentUid = uid;
this.currentButton = button;
this.isPlaying = true;
this.updateButtonState(button, 'loading');
// Notify global audio manager that personal player is starting
globalAudioManager.startPlayback('personal', uid);
try {
// Only clean up if switching streams
if (this.currentUid !== uid) {
this.cleanup();
}
// Store the current button reference
this.currentButton = button;
this.currentUid = uid;
// Create a new audio element if we don't have one
if (!this.audioElement) {
this.audioElement = new Audio();
} else if (this.audioElement.readyState > 0) {
// If we already have a loaded source, just play it
try {
await this.audioElement.play();
this.isPlaying = true;
this.updateButtonState(button, 'playing');
return;
} catch (playError) {
console.warn('Error playing existing source, will reload:', playError);
// Continue to load a new source
}
}
// Clear any existing sources
while (this.audioElement.firstChild) {
this.audioElement.removeChild(this.audioElement.firstChild);
}
// Set the source URL with proper encoding and cache-busting timestamp
// Using the format: /audio/{uid}/stream.opus?t={timestamp}
// Only update timestamp if we're loading a different UID or after a retry
const timestamp = this.retryCount > 0 ? new Date().getTime() : this.lastLoadTime;
this.audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${timestamp}`;
console.log(`[AudioPlayer] Loading audio from URL: ${this.audioUrl} (attempt ${this.retryCount + 1}/${this.maxRetries})`);
console.log('Loading audio from URL:', this.audioUrl);
this.audioElement.src = this.audioUrl;
// Load the new source (don't await, let canplay handle it)
try {
this.audioElement.load();
// If load() doesn't throw, we'll wait for canplay event
} catch (e) {
// Ignore abort errors as they're expected during rapid toggling
if (e.name !== 'AbortError') {
console.error('Error loading audio source:', e);
this.isLoading = false;
this.updateButtonState(button, 'error');
}
}
// Reset the current time when loading a new source
this.audioElement.currentTime = 0;
this.lastPlayTime = 0;
// Set up error handling
this.audioElement.onerror = (e) => {
console.error('Audio element error:', e, this.audioElement.error);
this.isLoading = false;
this.updateButtonState(button, 'error');
};
// Handle when audio is ready to play
const onCanPlay = () => {
this.audioElement.removeEventListener('canplay', onCanPlay);
this.isLoading = false;
if (this.lastPlayTime > 0) {
this.audioElement.currentTime = this.lastPlayTime;
}
this.audioElement.play().then(() => {
this.isPlaying = true;
this.updateButtonState(button, 'playing');
}).catch(e => {
console.error('Error playing after load:', e);
this.updateButtonState(button, 'error');
});
};
// Define the error handler
const errorHandler = (e) => {
console.error('Audio element error:', e, this.audioElement.error);
this.isLoading = false;
this.updateButtonState(button, 'error');
};
// Define the play handler
const playHandler = () => {
// Clear any pending timeouts
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.audioElement.removeEventListener('canplay', playHandler);
this.isLoading = false;
if (this.lastPlayTime > 0) {
this.audioElement.currentTime = this.lastPlayTime;
}
this.audioElement.play().then(() => {
this.isPlaying = true;
this.updateButtonState(button, 'playing');
}).catch(e => {
console.error('Error playing after load:', e);
this.isPlaying = false;
this.updateButtonState(button, 'error');
});
};
// Add event listeners
this.audioElement.addEventListener('error', errorHandler, { once: true });
this.audioElement.addEventListener('canplay', playHandler, { once: true });
// Load and play the new source
try {
await this.audioElement.load();
// Don't await play() here, let the canplay handler handle it
// Set a timeout to handle cases where canplay doesn't fire
this.loadTimeout = setTimeout(() => {
if (this.isLoading) {
console.warn('Audio loading timed out for UID:', uid);
this.isLoading = false;
this.updateButtonState(button, 'error');
}
}, 10000); // 10 second timeout
} catch (e) {
console.error('Error loading audio:', e);
this.isLoading = false;
this.updateButtonState(button, 'error');
// Clear any pending timeouts
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
}
} catch (error) {
console.error('Error in loadAndPlay:', error);
// Only cleanup and show error if we're still on the same track
if (this.currentUid === uid) {
this.cleanup();
this.updateButtonState(button, 'error');
}
}
}
/**
* Stop playback and clean up resources
*/
stop() {
try {
if (this.audioElement) {
console.log('Stopping audio playback');
this.audioElement.pause();
this.lastPlayTime = this.audioElement.currentTime;
this.isPlaying = false;
// Notify global audio manager that personal player has stopped
globalAudioManager.stopPlayback('personal');
if (this.currentButton) {
this.updateButtonState(this.currentButton, 'paused');
}
}
} catch (error) {
console.error('Error stopping audio:', error);
// Don't throw, just log the error
}
}
/**
* Set up event listeners for the audio element
*/
setupEventListeners() {
if (!this.audioElement) return;
// Remove any existing listeners to prevent duplicates
this.audioElement.removeEventListener('error', this.handlePlayError);
this.audioElement.removeEventListener('stalled', this.handleStalled);
this.audioElement.removeEventListener('waiting', this.handleWaiting);
this.audioElement.removeEventListener('playing', this.handlePlaying);
this.audioElement.removeEventListener('ended', this.handleEnded);
// Add new listeners
this.audioElement.addEventListener('error', this.handlePlayError);
this.audioElement.addEventListener('stalled', this.handleStalled);
this.audioElement.addEventListener('waiting', this.handleWaiting);
this.audioElement.addEventListener('playing', this.handlePlaying);
this.audioElement.addEventListener('ended', this.handleEnded);
}
/**
* Handle play errors
*/
handlePlayError(event) {
console.error('[AudioPlayer] Playback error:', {
event: event.type,
error: this.audioElement.error,
currentTime: this.audioElement.currentTime,
readyState: this.audioElement.readyState,
networkState: this.audioElement.networkState,
src: this.audioElement.src
});
this.isPlaying = false;
this.buffering = false;
this.pendingLoad = false;
if (this.currentButton) {
this.updateButtonState(this.currentButton, 'error');
}
// Auto-retry logic
if (this.retryCount < this.maxRetries) {
this.retryCount++;
console.log(`Retrying playback (attempt ${this.retryCount}/${this.maxRetries})...`);
setTimeout(() => {
if (this.currentUid && this.currentButton) {
this.loadAndPlay(this.currentUid, this.currentButton);
}
}, this.retryDelay);
} else {
console.error('Max retry attempts reached');
this.retryCount = 0; // Reset for next time
}
}
/**
* Handle stalled audio (buffering issues)
*/
handleStalled() {
console.log('[AudioPlayer] Playback stalled, attempting to recover...');
this.buffering = true;
if (this.bufferRetryTimeout) {
clearTimeout(this.bufferRetryTimeout);
}
this.bufferRetryTimeout = setTimeout(() => {
if (this.buffering) {
console.log('[AudioPlayer] Buffer recovery timeout, attempting to reload...');
if (this.currentUid && this.currentButton) {
// Only retry if we're still supposed to be playing
if (this.isPlaying) {
this.retryCount++;
if (this.retryCount <= this.maxRetries) {
console.log(`[AudioPlayer] Retry ${this.retryCount}/${this.maxRetries} for UID: ${this.currentUid}`);
this.loadAndPlay(this.currentUid, this.currentButton);
} else {
console.error('[AudioPlayer] Max retry attempts reached');
this.retryCount = 0;
this.updateButtonState(this.currentButton, 'error');
}
}
}
}
}, 5000); // 5 second buffer recovery timeout
}
/**
* Handle waiting event (buffering)
*/
handleWaiting() {
console.log('Audio waiting for data...');
this.buffering = true;
if (this.currentButton) {
this.updateButtonState(this.currentButton, 'loading');
}
}
/**
* Handle playing event (playback started/resumed)
*/
handlePlaying() {
console.log('Audio playback started/resumed');
this.buffering = false;
this.retryCount = 0; // Reset retry counter on successful playback
if (this.bufferRetryTimeout) {
clearTimeout(this.bufferRetryTimeout);
this.bufferRetryTimeout = null;
}
if (this.currentButton) {
this.updateButtonState(this.currentButton, 'playing');
}
}
/**
* Handle ended event (playback completed)
*/
handleEnded() {
console.log('Audio playback ended');
this.isPlaying = false;
this.buffering = false;
if (this.currentButton) {
this.updateButtonState(this.currentButton, 'paused');
}
}
/**
* Clean up resources
*/
cleanup() {
// Clear any pending timeouts
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
if (this.bufferRetryTimeout) {
clearTimeout(this.bufferRetryTimeout);
this.bufferRetryTimeout = null;
}
// Update button state if we have a reference to the current button
if (this.currentButton) {
this.updateButtonState(this.currentButton, 'paused');
}
// Pause the audio and store the current time
if (this.audioElement) {
try {
// Remove event listeners to prevent memory leaks
this.audioElement.removeEventListener('error', this.handlePlayError);
this.audioElement.removeEventListener('stalled', this.handleStalled);
this.audioElement.removeEventListener('waiting', this.handleWaiting);
this.audioElement.removeEventListener('playing', this.handlePlaying);
this.audioElement.removeEventListener('ended', this.handleEnded);
try {
this.audioElement.pause();
this.lastPlayTime = this.audioElement.currentTime;
} catch (e) {
console.warn('Error pausing audio during cleanup:', e);
}
try {
// Clear any existing sources
while (this.audioElement.firstChild) {
this.audioElement.removeChild(this.audioElement.firstChild);
}
// Clear the source and reset the audio element
this.audioElement.removeAttribute('src');
try {
this.audioElement.load();
} catch (e) {
console.warn('Error in audio load during cleanup:', e);
}
} catch (e) {
console.warn('Error cleaning up audio sources:', e);
}
} catch (e) {
console.warn('Error during audio cleanup:', e);
}
}
// Reset state
this.currentUid = null;
this.currentButton = null;
this.audioUrl = '';
this.isPlaying = false;
this.buffering = false;
this.retryCount = 0;
// Notify global audio manager that personal player has stopped
globalAudioManager.stopPlayback('personal');
}
/**
* Update the state of a play/pause button
* @param {HTMLElement} button - The button to update
* @param {string} state - The state to set ('playing', 'paused', 'loading', 'error')
*/
updateButtonState(button, state) {
if (!button) return;
// Only update the current button's state
if (state === 'playing') {
// If this button is now playing, update all buttons
document.querySelectorAll('.play-pause-btn').forEach(btn => {
btn.classList.remove('playing', 'paused', 'loading', 'error');
if (btn === button) {
btn.classList.add('playing');
} else {
btn.classList.add('paused');
}
});
} else {
// For other states, just update the target button
button.classList.remove('playing', 'paused', 'loading', 'error');
if (state) {
button.classList.add(state);
}
}
// Update button icon and aria-label for the target button
const icon = button.querySelector('i');
if (icon) {
if (state === 'playing') {
icon.className = 'fas fa-pause';
button.setAttribute('aria-label', 'Pause');
} else {
icon.className = 'fas fa-play';
button.setAttribute('aria-label', 'Play');
}
}
}
}
// Create a singleton instance
export const audioPlayer = new AudioPlayer();
// Export utility functions for direct use
export function initAudioPlayer(container = document) {
// Set up event delegation for play/pause buttons
container.addEventListener('click', (e) => {
const playButton = e.target.closest('.play-pause-btn');
if (!playButton) return;
e.preventDefault();
e.stopPropagation();
const uid = playButton.dataset.uid;
if (!uid) return;
audioPlayer.loadAndPlay(uid, playButton);
});
// Set up event delegation for stop buttons if they exist
container.addEventListener('click', (e) => {
const stopButton = e.target.closest('.stop-btn');
if (!stopButton) return;
e.preventDefault();
e.stopPropagation();
audioPlayer.stop();
});
}
// Auto-initialize if this is the main module
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
initAudioPlayer();
});
}