/ app / services / video / controller-helpers-commands.js
controller-helpers-commands.js
  1  import { getAudioStateManager } from '../audio/audio-state-manager.js';
  2  import { createLogger } from '../../utils/debug/logger.js';
  3  
  4  const logger = createLogger('VideoCommands');
  5  
  6  export function enqueueCommand(vcInstance, commandFunction, options = {}) {
  7    const { description = 'Unknown command', unmuteBeforeExecution = false } = options;
  8  
  9    return new Promise((resolve, reject) => {
 10      if (!vcInstance || !vcInstance.videoElement) {
 11        reject(new Error('VideoController instance is invalid or cleaned up.'));
 12        return;
 13      }
 14  
 15      const wrappedCommand = () => {
 16        if (unmuteBeforeExecution && vcInstance.videoElement.muted && vcInstance.hasUserInteracted) {
 17          const audioStateManager = getAudioStateManager();
 18  
 19          if (!audioStateManager.videoElement) {
 20            audioStateManager.initialize(vcInstance.videoElement);
 21          }
 22  
 23          audioStateManager.ensureUnmuted(vcInstance.hasUserInteracted);
 24        }
 25        return commandFunction();
 26      };
 27  
 28      vcInstance.commandQueue.push({
 29        description,
 30        execute: wrappedCommand,
 31        reject,
 32        resolve,
 33      });
 34  
 35      setTimeout(() => {
 36        if (vcInstance && !vcInstance.isProcessingCommandQueue) {
 37          _processCommandQueue(vcInstance);
 38        }
 39      }, 0);
 40    });
 41  }
 42  
 43  export async function _processCommandQueue(vcInstance) {
 44    if (vcInstance.isProcessingCommandQueue || !vcInstance || !vcInstance.videoElement) {
 45      return;
 46    }
 47    vcInstance.isProcessingCommandQueue = true;
 48    while (vcInstance.commandQueue.length > 0) {
 49      if (!vcInstance || !vcInstance.videoElement) {
 50        logger.warn('VideoController invalid during queue processing. Aborting.');
 51  
 52        vcInstance.commandQueue = [];
 53        break;
 54      }
 55  
 56      const { description, execute, reject, resolve } = vcInstance.commandQueue.shift();
 57      const commandDesc = description || 'Unknown command';
 58  
 59      try {
 60        const result = await execute();
 61  
 62        resolve(result);
 63      } catch (error) {
 64        logger.warn(`Command failed: ${commandDesc}`, error);
 65  
 66        reject(error);
 67      }
 68    }
 69    vcInstance.isProcessingCommandQueue = false;
 70  }
 71  
 72  function validateVideoController(vcInstance) {
 73    if (!vcInstance) {
 74      throw new Error('VideoController instance is null');
 75    }
 76  
 77    if (!vcInstance.videoElement) {
 78      logger.warn('Play command failed: Video element is null', {
 79        currentCid: vcInstance.currentCid || 'none',
 80        hasController: Boolean(vcInstance),
 81        isProcessingCommandQueue: vcInstance.isProcessingCommandQueue,
 82        queueLength: vcInstance.commandQueue?.length || 0,
 83      });
 84      throw new Error('Video element not available');
 85    }
 86  
 87    if (!vcInstance.currentCid) {
 88      throw new Error('Cannot play: No video source loaded (missing CID)');
 89    }
 90  }
 91  
 92  export async function forceMetadataLoading(vcInstance, timeoutMs = 5000) {
 93    if (!vcInstance || !vcInstance.videoElement) {
 94      logger.warn('Cannot force metadata loading: Video element not available');
 95      return false;
 96    }
 97  
 98    const videoElement = vcInstance.videoElement;
 99  
100    if (videoElement.readyState >= HTMLMediaElement.HAVE_METADATA) {
101      logger.debug('Metadata already loaded, no need to force loading');
102      return true;
103    }
104  
105    logger.info('Forcing metadata loading with timeout:', timeoutMs);
106  
107    return new Promise(resolve => {
108      const timeoutId = setTimeout(() => {
109        logger.warn(`Metadata loading timed out after ${timeoutMs}ms`);
110        cleanup();
111        resolve(false);
112      }, timeoutMs);
113  
114      const loadedMetadataHandler = () => {
115        logger.info('Metadata loaded successfully');
116        cleanup();
117        resolve(true);
118      };
119  
120      const errorHandler = error => {
121        logger.error('Error during metadata loading:', error);
122        cleanup();
123        resolve(false);
124      };
125  
126      const cleanup = () => {
127        videoElement.removeEventListener('loadedmetadata', loadedMetadataHandler);
128        videoElement.removeEventListener('error', errorHandler);
129        clearTimeout(timeoutId);
130      };
131  
132      videoElement.addEventListener('loadedmetadata', loadedMetadataHandler);
133      videoElement.addEventListener('error', errorHandler);
134  
135      if (videoElement.readyState === 0) {
136        try {
137          videoElement.load();
138        } catch (e) {
139          logger.warn('Error calling load() to force metadata loading:', e);
140        }
141      }
142    });
143  }
144  
145  async function prepareVideoForPlayback(vcInstance) {
146    if (!vcInstance.videoElement.paused) {
147      return false;
148    }
149  
150    if (vcInstance.videoElement.ended) {
151      vcInstance.videoElement.currentTime = 0;
152    }
153  
154    if (vcInstance.videoElement.readyState < HTMLMediaElement.HAVE_METADATA) {
155      logger.info('Waiting for metadata to load before playback');
156      await forceMetadataLoading(vcInstance, 5000);
157    }
158  
159    return true;
160  }
161  
162  function handleAudioState(vcInstance) {
163    if (!vcInstance.hasUserInteracted) {
164      if (!vcInstance.videoElement.muted) {
165        vcInstance.videoElement.muted = true;
166      }
167    } else {
168      const audioStateManager = getAudioStateManager();
169  
170      if (!audioStateManager.videoElement) {
171        audioStateManager.initialize(vcInstance.videoElement);
172      }
173  
174      audioStateManager.ensureUnmuted(vcInstance.hasUserInteracted);
175    }
176  }
177  
178  async function attemptPlayErrorRecovery(vcInstance, error) {
179    if (error.name === 'NotAllowedError') {
180      return;
181    }
182  
183    logger.warn(`Play command failed: ${error.name} - ${error.message}`);
184  
185    if (!vcInstance.videoElement?.src) {
186      return;
187    }
188  
189    try {
190      logger.info('Attempting to reload video after play error...');
191      const { currentTime } = vcInstance.videoElement;
192  
193      vcInstance.videoElement.load();
194      vcInstance.videoElement.currentTime = currentTime;
195  
196      await vcInstance.videoElement.play();
197    } catch (retryError) {
198      logger.warn('Failed to play after reload:', retryError);
199    }
200  }
201  
202  async function _playInternal(vcInstance) {
203    validateVideoController(vcInstance);
204  
205    const shouldContinue = await prepareVideoForPlayback(vcInstance);
206    if (!shouldContinue) {
207      return;
208    }
209  
210    vcInstance.playbackState.isPlaying = true;
211    vcInstance._hasAttemptedPlay = true;
212  
213    handleAudioState(vcInstance);
214  
215    try {
216      await vcInstance.videoElement.play();
217    } catch (error) {
218      vcInstance.playbackState.isPlaying = !vcInstance.videoElement.paused;
219      vcInstance._emitCustomEvent('playbackError', { error });
220  
221      await attemptPlayErrorRecovery(vcInstance, error).catch(recoveryError => {
222        logger.warn('Error during play recovery attempt:', recoveryError);
223      });
224    }
225  }
226  
227  function _pauseInternal(vcInstance) {
228    if (!vcInstance.videoElement) {
229      return Promise.reject('Video element not available');
230    }
231    if (vcInstance.videoElement.paused) {
232      return Promise.resolve();
233    }
234    try {
235      vcInstance.videoElement.pause();
236  
237      return Promise.resolve();
238    } catch (error) {
239      return Promise.reject(error);
240    }
241  }
242  
243  export function playCommand(vcInstance) {
244    return enqueueCommand(vcInstance, () => _playInternal(vcInstance), {
245      description: 'Play Command',
246      unmuteBeforeExecution: true,
247    });
248  }
249  
250  export function pauseCommand(vcInstance) {
251    return enqueueCommand(vcInstance, () => _pauseInternal(vcInstance), {
252      description: 'Pause Command',
253    });
254  }
255  
256  export function seekToCommand(vcInstance, timeSeconds) {
257    if (!Number.isFinite(timeSeconds)) {
258      return Promise.reject(new Error(`Invalid seek target time: ${timeSeconds}`));
259    }
260  
261    if (!vcInstance || !vcInstance.videoElement) {
262      return Promise.reject(new Error('Cannot seek: Video element not available'));
263    }
264  
265    const validatedTime = Math.max(0, timeSeconds);
266  
267    return enqueueCommand(
268      vcInstance,
269      () => {
270        return new Promise((resolve, reject) => {
271          try {
272            _performSimpleSeek(vcInstance, validatedTime, resolve, reject);
273          } catch (error) {
274            reject(error);
275          }
276        });
277      },
278      {
279        description: `Seek To ${validatedTime.toFixed(2)}s Command`,
280        unmuteBeforeExecution: false,
281      },
282    );
283  }
284  
285  async function _performSimpleSeek(vcInstance, targetTime, resolve, reject) {
286    try {
287      if (vcInstance.videoElement.readyState < HTMLMediaElement.HAVE_METADATA) {
288        logger.info('Waiting for metadata to load before seeking');
289        await forceMetadataLoading(vcInstance, 5000);
290      }
291  
292      const duration = vcInstance.videoElement.duration;
293      const clampedTime =
294        Number.isFinite(duration) && duration > 0 ? Math.min(targetTime, duration) : targetTime;
295  
296      vcInstance.playbackState.isSeeking = true;
297  
298      vcInstance.videoElement.currentTime = clampedTime;
299  
300      vcInstance.playbackState.currentTimeSeconds = clampedTime;
301      vcInstance.playbackState.isSeeking = false;
302  
303      resolve();
304    } catch (error) {
305      logger.error(`Simple seek error: ${error.message}`);
306      vcInstance.playbackState.isSeeking = false;
307      reject(error);
308    }
309  }
310  
311  export async function seekByCommand(vcInstance, deltaSeconds) {
312    if (!Number.isFinite(deltaSeconds)) {
313      return Promise.reject(new Error(`Invalid seek delta time: ${deltaSeconds}`));
314    }
315  
316    if (!vcInstance || !vcInstance.videoElement) {
317      return Promise.reject(new Error('Cannot seek: Video element not available'));
318    }
319  
320    const currentTime = vcInstance.videoElement.currentTime;
321  
322    const targetTime = Number.isFinite(currentTime)
323      ? Math.max(0, currentTime + deltaSeconds)
324      : Math.max(0, deltaSeconds);
325  
326    return seekToCommand(vcInstance, targetTime);
327  }