/ @reviews / 03-audio-state-management.md
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.