03-audio-state-management.md
1 # Audio State Management Optimization 2 3 ## Problem Statement 4 5 The logs show several inefficiencies in how DaokoTube manages audio state: 6 7 1. Repeated unmute checks when the video is already unmuted 8 2. Multiple log entries for "Video is already unmuted" 9 3. Redundant unmute operations that don't change the state 10 4. No cooldown between unmute attempts 11 5. Lack of centralized audio state tracking 12 13 These issues lead to unnecessary processing, console spam, and potential user experience issues if unmute operations are triggered too frequently. 14 15 ## Proposed Solution 16 17 Implement a state-based AudioStateManager that: 18 19 1. Tracks the current audio state centrally 20 2. Implements a cooldown period between unmute attempts 21 3. Only performs unmute operations when necessary 22 4. Provides a clean API for audio state management 23 5. Handles browser autoplay restrictions gracefully 24 25 ## Implementation Plan 26 27 ### 1. Create an Audio State Manager 28 29 ```javascript 30 // app/services/audio/audio-state-manager.js 31 32 import { createLogger } from '../../utils/debug/logger.js'; 33 import { EventEmitter } from '../../utils/events/event-emitter.js'; 34 35 const logger = createLogger('AudioStateManager'); 36 37 // Constants 38 const UNMUTE_COOLDOWN = 1000; // 1 second cooldown between unmute attempts 39 const UNMUTE_CHECK_INTERVAL = 500; // Check every 500ms during verification 40 const UNMUTE_VERIFICATION_TIMEOUT = 2000; // 2 seconds to verify unmute 41 42 // Audio state events 43 export const AudioStateEvents = { 44 UNMUTED: 'audio_unmuted', 45 MUTED: 'audio_muted', 46 UNMUTE_FAILED: 'audio_unmute_failed', 47 STATE_CHANGED: 'audio_state_changed' 48 }; 49 50 export class AudioStateManager { 51 constructor() { 52 this.unmuted = false; 53 this.lastUnmuteTime = 0; 54 this.unmutePending = false; 55 this.verificationTimer = null; 56 this.hasUserInteracted = false; 57 this.events = new EventEmitter(); 58 this.videoElement = null; 59 } 60 61 /** 62 * Initialize the audio state manager with a video element 63 * @param {HTMLVideoElement} videoElement - The video element to manage 64 */ 65 initialize(videoElement) { 66 this.videoElement = videoElement; 67 68 // Reset state 69 this.unmuted = !videoElement.muted; 70 this.unmutePending = false; 71 72 // Clear any existing timers 73 if (this.verificationTimer) { 74 clearInterval(this.verificationTimer); 75 this.verificationTimer = null; 76 } 77 78 logger.info(`Audio state manager initialized, initial muted state: ${videoElement.muted}`); 79 } 80 81 /** 82 * Set whether the user has interacted with the page 83 * @param {boolean} hasInteracted - Whether the user has interacted 84 */ 85 setUserInteraction(hasInteracted) { 86 const previousState = this.hasUserInteracted; 87 this.hasUserInteracted = hasInteracted; 88 89 if (hasInteracted && !previousState) { 90 logger.info('User interaction detected, will attempt to unmute video'); 91 this.unmute(); 92 } 93 } 94 95 /** 96 * Check if unmute should be attempted 97 * @returns {boolean} - Whether unmute should be attempted 98 */ 99 shouldUnmute() { 100 if (!this.videoElement) { 101 return false; 102 } 103 104 // If already unmuted, no need to unmute 105 if (!this.videoElement.muted) { 106 this.unmuted = true; 107 return false; 108 } 109 110 // If unmute is pending, don't try again 111 if (this.unmutePending) { 112 return false; 113 } 114 115 // Check cooldown 116 const now = Date.now(); 117 if (now - this.lastUnmuteTime < UNMUTE_COOLDOWN) { 118 logger.debug('Skipping unmute check - recently attempted'); 119 return false; 120 } 121 122 // If no user interaction, can't unmute 123 if (!this.hasUserInteracted) { 124 logger.debug('Cannot unmute - no user interaction yet'); 125 return false; 126 } 127 128 return true; 129 } 130 131 /** 132 * Attempt to unmute the video 133 * @returns {boolean} - Whether unmute was attempted 134 */ 135 unmute() { 136 if (!this.shouldUnmute()) { 137 return false; 138 } 139 140 logger.info('Video was muted, unmuting now'); 141 142 this.unmutePending = true; 143 this.lastUnmuteTime = Date.now(); 144 145 try { 146 // Store original muted state 147 const wasMuted = this.videoElement.muted; 148 149 // Attempt to unmute 150 this.videoElement.muted = false; 151 152 // If muted state didn't change, might be browser restriction 153 if (this.videoElement.muted === wasMuted && wasMuted === true) { 154 logger.warn('Failed to unmute video - browser may be blocking autoplay with sound'); 155 this._handleUnmuteFailed(); 156 return false; 157 } 158 159 // Start verification process 160 this._verifyUnmute(); 161 return true; 162 } catch (error) { 163 logger.warn('Error unmuting video:', error); 164 this._handleUnmuteFailed(); 165 return false; 166 } 167 } 168 169 /** 170 * Mute the video 171 * @returns {boolean} - Whether mute was successful 172 */ 173 mute() { 174 if (!this.videoElement) { 175 return false; 176 } 177 178 try { 179 this.videoElement.muted = true; 180 this.unmuted = false; 181 this.unmutePending = false; 182 183 // Clear verification timer 184 if (this.verificationTimer) { 185 clearInterval(this.verificationTimer); 186 this.verificationTimer = null; 187 } 188 189 logger.info('Video muted'); 190 this.events.emit(AudioStateEvents.MUTED); 191 this.events.emit(AudioStateEvents.STATE_CHANGED, { muted: true }); 192 193 return true; 194 } catch (error) { 195 logger.warn('Error muting video:', error); 196 return false; 197 } 198 } 199 200 /** 201 * Verify that unmute was successful 202 * @private 203 */ 204 _verifyUnmute() { 205 // Clear any existing verification timer 206 if (this.verificationTimer) { 207 clearInterval(this.verificationTimer); 208 } 209 210 const startTime = Date.now(); 211 let verificationAttempts = 0; 212 213 this.verificationTimer = setInterval(() => { 214 verificationAttempts++; 215 216 // Check if video is actually unmuted 217 if (!this.videoElement.muted) { 218 this._handleUnmuteSuccess(); 219 clearInterval(this.verificationTimer); 220 this.verificationTimer = null; 221 return; 222 } 223 224 // Check if we've exceeded the timeout 225 if (Date.now() - startTime > UNMUTE_VERIFICATION_TIMEOUT) { 226 logger.warn(`Unmute verification timed out after ${verificationAttempts} attempts`); 227 this._handleUnmuteFailed(); 228 clearInterval(this.verificationTimer); 229 this.verificationTimer = null; 230 return; 231 } 232 233 // Try unmuting again 234 try { 235 this.videoElement.muted = false; 236 } catch (error) { 237 logger.warn('Error during unmute verification:', error); 238 } 239 }, UNMUTE_CHECK_INTERVAL); 240 } 241 242 /** 243 * Handle successful unmute 244 * @private 245 */ 246 _handleUnmuteSuccess() { 247 this.unmuted = true; 248 this.unmutePending = false; 249 250 logger.info('Unmute verified successful'); 251 this.events.emit(AudioStateEvents.UNMUTED); 252 this.events.emit(AudioStateEvents.STATE_CHANGED, { muted: false }); 253 } 254 255 /** 256 * Handle failed unmute 257 * @private 258 */ 259 _handleUnmuteFailed() { 260 this.unmuted = false; 261 this.unmutePending = false; 262 263 logger.warn('Failed to unmute video'); 264 this.events.emit(AudioStateEvents.UNMUTE_FAILED); 265 this.events.emit(AudioStateEvents.STATE_CHANGED, { muted: true }); 266 } 267 268 /** 269 * Reset the audio state manager 270 */ 271 reset() { 272 this.unmuted = false; 273 this.unmutePending = false; 274 275 // Clear verification timer 276 if (this.verificationTimer) { 277 clearInterval(this.verificationTimer); 278 this.verificationTimer = null; 279 } 280 281 logger.info('Audio state manager reset'); 282 } 283 284 /** 285 * Subscribe to audio state events 286 * @param {string} event - The event to subscribe to 287 * @param {Function} callback - The callback function 288 * @returns {Function} - Unsubscribe function 289 */ 290 subscribe(event, callback) { 291 return this.events.subscribe(event, callback); 292 } 293 } 294 295 // Export singleton instance 296 export default new AudioStateManager(); 297 ``` 298 299 ### 2. Integrate with VideoController 300 301 Update the VideoController to use the AudioStateManager: 302 303 ```javascript 304 // app/services/video/controller.js 305 306 import audioStateManager, { AudioStateEvents } from '../audio/audio-state-manager.js'; 307 308 // In the constructor 309 constructor(videoElement) { 310 // ... existing code ... 311 312 // Initialize audio state manager 313 audioStateManager.initialize(videoElement); 314 315 // Subscribe to audio state events 316 this.subscriptions.add( 317 audioStateManager.subscribe(AudioStateEvents.STATE_CHANGED, this._handleAudioStateChanged.bind(this)) 318 ); 319 } 320 321 // Replace _ensureVideoUnmuted method 322 _ensureVideoUnmuted() { 323 if (this.hasUserInteracted) { 324 audioStateManager.setUserInteraction(true); 325 return audioStateManager.unmute(); 326 } 327 return false; 328 } 329 330 // Add handler for audio state changes 331 _handleAudioStateChanged(state) { 332 // Update UI or trigger other actions based on audio state 333 if (!state.muted) { 334 // Audio is unmuted, update UI if needed 335 } else { 336 // Audio is muted, update UI if needed 337 } 338 } 339 340 // Update handleUserInteraction method 341 handleUserInteraction() { 342 this.hasUserInteracted = true; 343 audioStateManager.setUserInteraction(true); 344 345 // ... existing code ... 346 } 347 348 // Update cleanup method 349 cleanup() { 350 // ... existing code ... 351 352 audioStateManager.reset(); 353 } 354 ``` 355 356 ### 3. Integrate with App.js 357 358 Update App.js to handle visibility changes with audio state: 359 360 ```javascript 361 // app/app.js 362 363 import audioStateManager from './services/audio/audio-state-manager.js'; 364 365 // In the handleVisibilityChange method 366 handleVisibilityChange() { 367 const isVisible = document.visibilityState === 'visible'; 368 369 if (!isVisible) { 370 // Page is hidden, pause video 371 if (this.videoController && !this.videoController.videoElement.paused) { 372 console.info('Video was playing, pausing due to visibility change'); 373 this.videoController.pause(); 374 } 375 } else { 376 // Page is visible again, resume if it was playing 377 if (this.videoController && this._wasPlayingBeforeHidden) { 378 console.info('Page became visible, resuming video playback'); 379 380 // Ensure audio state is correct before resuming 381 if (this.videoController.hasUserInteracted) { 382 audioStateManager.unmute(); 383 } 384 385 this.videoController.play(); 386 } 387 } 388 } 389 ``` 390 391 ## Expected Benefits 392 393 1. **Reduced Redundant Operations**: By tracking audio state centrally, we'll eliminate redundant unmute operations. 394 2. **Improved User Experience**: Better handling of browser autoplay restrictions will improve the user experience. 395 3. **Reduced Console Spam**: Fewer log entries for audio state changes will reduce console spam. 396 4. **Better Error Handling**: Proper error handling for unmute operations will make the application more robust. 397 5. **Cleaner Code**: A centralized API for audio state management will make the code cleaner and more maintainable. 398 399 ## Testing Plan 400 401 1. **Unit Tests**: Create unit tests for the AudioStateManager class. 402 2. **Integration Tests**: Test the integration with VideoController and App.js. 403 3. **Browser Tests**: Test in different browsers to ensure compatibility with various autoplay policies. 404 4. **User Interaction Tests**: Test with and without user interaction to ensure proper behavior. 405 5. **Edge Case Tests**: Test edge cases like rapid mute/unmute operations and browser restrictions. 406 407 ## Rollout Plan 408 409 1. **Development**: Implement the changes in a development environment. 410 2. **Testing**: Test the changes thoroughly. 411 3. **Staging**: Deploy to a staging environment for further testing. 412 4. **Production**: Roll out to production, monitoring for any issues. 413 5. **Monitoring**: Monitor for any audio-related issues or user complaints.