/ app / services / video / controller-helpers-events.js
controller-helpers-events.js
  1  import recoveryCoordinator from './recovery-coordinator.js';
  2  import * as videoRouter from './router.js';
  3  import { VideoEventData, VideoEvents } from './video-events.js';
  4  import { createLogger } from '../../utils/debug/logger.js';
  5  
  6  const logger = createLogger('VideoEvents');
  7  
  8  async function getModuleInstance(modulePath, globalGetter, useDefault = false) {
  9    const globalInstance = globalGetter?.();
 10    if (globalInstance) {
 11      return globalInstance;
 12    }
 13  
 14    const importedModule = await import(modulePath);
 15    return useDefault ? importedModule.default : importedModule;
 16  }
 17  
 18  export function _attachVideoEventListeners(vcInstance) {
 19    if (!vcInstance.videoElement) {
 20      return;
 21    }
 22    _removeAllEventListeners(vcInstance);
 23  
 24    const handlers = {
 25      canplay: _handleCanPlay.bind(null, vcInstance),
 26      durationchange: _handleDurationChange.bind(null, vcInstance),
 27      ended: _handleEnded.bind(null, vcInstance),
 28      error: _handleError.bind(null, vcInstance),
 29      loadedmetadata: _handleMetadataLoaded.bind(null, vcInstance),
 30      pause: _handlePause.bind(null, vcInstance),
 31      play: _handlePlay.bind(null, vcInstance),
 32      playing: _handlePlaying.bind(null, vcInstance),
 33      seeked: _handleSeekingEnd.bind(null, vcInstance),
 34      seeking: _handleSeekingStart.bind(null, vcInstance),
 35      stalled: _handleStalled.bind(null, vcInstance),
 36      timeupdate: vcInstance.timeUpdateThrottler,
 37      volumechange: _handleVolumeChange.bind(null, vcInstance),
 38      waiting: _handleWaiting.bind(null, vcInstance),
 39    };
 40  
 41    for (const [eventName, handler] of Object.entries(handlers)) {
 42      try {
 43        vcInstance.videoElement.addEventListener(eventName, handler);
 44        vcInstance.registeredEventListeners.set(handler, { eventName, handler });
 45      } catch (error) {
 46        console.warn(`Failed to attach listener for event "${eventName}":`, error);
 47      }
 48    }
 49  
 50    try {
 51      const corsErrorHandler = event => {
 52        const mediaError = vcInstance.videoElement.error;
 53        if (mediaError) {
 54          if (mediaError.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) {
 55            console.warn('Media error detected, possibly CORS related:', mediaError.message);
 56  
 57            const currentCid = vcInstance.currentCid;
 58            if (currentCid) {
 59              getModuleInstance('./sources.js', () => window.getVideoSourcesInstance?.())
 60                .then(videoSources => {
 61                  const videoInfo = videoSources
 62                    .getSourceList()
 63                    .find(source => source.cid === currentCid);
 64                  if (videoInfo?.isLivestream) {
 65                    console.info('Attempting to recover from possible CORS error for livestream');
 66                    vcInstance.refreshVideoSources(currentCid, true).catch(err => {
 67                      console.error('Error during CORS error recovery:', err);
 68                    });
 69                  }
 70                })
 71                .catch(err => {
 72                  console.error('Error importing video sources during CORS error handling:', err);
 73                });
 74            }
 75          }
 76        }
 77      };
 78  
 79      vcInstance.videoElement.addEventListener('error', corsErrorHandler);
 80      vcInstance.registeredEventListeners.set(corsErrorHandler, {
 81        eventName: 'error',
 82        handler: corsErrorHandler,
 83      });
 84    } catch (error) {
 85      console.warn('Failed to attach CORS error handler:', error);
 86    }
 87  }
 88  
 89  export function _removeAllEventListeners(vcInstance) {
 90    if (!vcInstance.videoElement) {
 91      return;
 92    }
 93  
 94    if (vcInstance.registeredEventListeners.size > 0) {
 95      for (const [handler, listenerInfo] of vcInstance.registeredEventListeners.entries()) {
 96        try {
 97          vcInstance.videoElement.removeEventListener(listenerInfo.eventName, handler);
 98        } catch (error) {
 99          console.warn(`Failed to remove listener for event "${listenerInfo.eventName}":`, error);
100        }
101      }
102      vcInstance.registeredEventListeners.clear();
103    }
104  }
105  
106  export function _emitCustomEvent(vcInstance, eventName, detail = {}) {
107    if (!vcInstance.videoElement) {
108      return;
109    }
110  
111    try {
112      const eventData = {
113        ...detail,
114        timestamp: Date.now(),
115      };
116  
117      const customEvent = new CustomEvent(`video:${eventName}`, {
118        bubbles: true,
119        composed: true,
120        detail: eventData,
121      });
122      vcInstance.videoElement.dispatchEvent(customEvent);
123  
124      vcInstance.events.publish(`video:${eventName}`, eventData);
125    } catch (error) {
126      console.warn(`Failed to emit custom event "video:${eventName}":`, error);
127    }
128  }
129  
130  export function _handlePlay(vcInstance) {
131    if (!vcInstance || !vcInstance.videoElement) {
132      return;
133    }
134  
135    if (!vcInstance.playbackState.isPlaying) {
136      vcInstance.playbackState.isPlaying = true;
137  
138      vcInstance.events.publish(
139        VideoEvents.PLAYBACK_STARTED,
140        VideoEventData.createPlaybackStartedData(),
141      );
142    }
143    vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime;
144  }
145  
146  export function _handlePause(vcInstance) {
147    if (!vcInstance || !vcInstance.videoElement) {
148      return;
149    }
150  
151    vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime;
152  
153    if (vcInstance.playbackState.isPlaying) {
154      vcInstance.playbackState.isPlaying = false;
155  
156      vcInstance.events.publish(
157        VideoEvents.PLAYBACK_PAUSED,
158        VideoEventData.createPlaybackPausedData(),
159      );
160    }
161  }
162  
163  export function _handleTimeUpdateInternal(vcInstance) {
164    if (!vcInstance || !vcInstance.videoElement) {
165      return;
166    }
167    vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime;
168  
169    const eventData = VideoEventData.createTimeUpdateData(
170      vcInstance.playbackState.currentTimeSeconds,
171    );
172  
173    vcInstance.events.publish(VideoEvents.TIME_UPDATE, eventData);
174  }
175  
176  export function _handleDurationChange(vcInstance) {
177    if (!vcInstance || !vcInstance.videoElement) {
178      return;
179    }
180  
181    const newDuration = vcInstance.videoElement.duration;
182    const oldDuration = vcInstance.playbackState.durationSeconds;
183  
184    if (
185      Number.isFinite(newDuration) &&
186      newDuration > 0 &&
187      (newDuration !== oldDuration || !Number.isFinite(oldDuration))
188    ) {
189      vcInstance.playbackState.durationSeconds = newDuration;
190  
191      const eventData = VideoEventData.createDurationChangeData(newDuration);
192  
193      vcInstance.events.publish(VideoEvents.DURATION_CHANGE, eventData);
194    } else if (newDuration !== oldDuration && (!Number.isFinite(newDuration) || newDuration <= 0)) {
195      vcInstance.playbackState.durationSeconds = Number.NaN;
196  
197      const eventData = VideoEventData.createDurationChangeData(Number.NaN);
198  
199      vcInstance.events.publish(VideoEvents.DURATION_CHANGE, eventData);
200    }
201  }
202  
203  function isValidEndedEvent(videoEl) {
204    const { currentTime, duration, readyState, networkState } = videoEl;
205  
206    return (
207      videoEl.ended &&
208      Number.isFinite(duration) &&
209      duration > 1 &&
210      Math.abs(currentTime - duration) < 0.5 &&
211      readyState >= 2 &&
212      networkState !== 2
213    );
214  }
215  
216  function handleValidEndedEvent(vcInstance, videoEl) {
217    const { currentTime, duration, readyState, networkState } = videoEl;
218  
219    vcInstance.playbackState.isPlaying = false;
220    vcInstance.playbackState.currentTimeSeconds =
221      Number.isFinite(duration) && duration > 0 ? duration : 0;
222  }
223  
224  function updateProvidersHealth(videoEl) {
225    const currentSources = [...videoEl.querySelectorAll('source')];
226    const currentProviders = currentSources.map(el => el.dataset.provider).filter(Boolean);
227  
228    for (const provider of currentProviders) {
229      if (provider) {
230        videoRouter.updateProviderHealth(provider, false);
231      }
232    }
233  }
234  
235  function handleUnmuting(vcInstance) {
236    if (!vcInstance.hasUserInteracted) {
237      return;
238    }
239  
240    vcInstance._ensureVideoUnmuted();
241  }
242  
243  async function handleFalseEndedEvent(vcInstance) {
244    if (!vcInstance.currentCid || !vcInstance.videoElement) {
245      console.warn('Cannot recover from false ended event: Missing CID or video element');
246      return;
247    }
248  
249    const cidStr = vcInstance.currentCid.slice(0, 8);
250  
251    const eventData = VideoEventData.createRecoveryData(vcInstance.currentCid, 'false_ended', false);
252  
253    vcInstance.events.publish(VideoEvents.RECOVERY_STARTED, eventData);
254  
255    try {
256      const success = await recoveryCoordinator.recoverFromFalseEnded(
257        vcInstance,
258        vcInstance.currentCid,
259      );
260  
261      if (success) {
262        handleUnmuting(vcInstance);
263      } else {
264        console.warn(
265          `Recovery coordinator failed to recover from false ended event for CID ${cidStr}`,
266        );
267  
268        const currentTime = vcInstance.videoElement.currentTime || 0;
269  
270        updateProvidersHealth(vcInstance.videoElement);
271  
272        await vcInstance.refreshVideoSources(vcInstance.currentCid, true, []);
273  
274        vcInstance.videoElement.currentTime = currentTime;
275  
276        handleUnmuting(vcInstance);
277  
278        vcInstance.videoElement.play().catch(playError => {
279          console.warn('Failed to resume playback after refreshing sources:', playError);
280        });
281      }
282    } catch (error) {
283      console.error(`Error during false ended recovery for CID ${cidStr}:`, error);
284    }
285  }
286  
287  export async function _handleEnded(vcInstance) {
288    if (!vcInstance || !vcInstance.videoElement) {
289      return;
290    }
291  
292    const videoEl = vcInstance.videoElement;
293    const { currentTime, duration, readyState, networkState } = videoEl;
294  
295    if (isValidEndedEvent(videoEl)) {
296      handleValidEndedEvent(vcInstance, videoEl);
297    } else {
298      console.warn(
299        `Suspicious 'ended' event ignored in controller (Time: ${currentTime.toFixed(1)}/${duration.toFixed(1)}, ReadyState: ${readyState}, NetworkState: ${networkState})`,
300      );
301  
302      vcInstance.playbackState.isPlaying = false;
303  
304      if (networkState === 2 || networkState === 3) {
305        try {
306          await handleFalseEndedEvent(vcInstance);
307        } catch (error) {
308          console.warn('Error while attempting to handle false ended error:', error);
309        }
310      }
311    }
312  }
313  
314  export function _handleVolumeChange(vcInstance) {
315    if (!vcInstance || !vcInstance.videoElement) {
316      return;
317    }
318  
319    const eventData = VideoEventData.createVolumeChangeData(
320      vcInstance.videoElement.muted,
321      vcInstance.videoElement.volume,
322    );
323  
324    vcInstance.events.publish(VideoEvents.VOLUME_CHANGE, eventData);
325  }
326  
327  export function _handleError(vcInstance) {
328    if (!vcInstance || !vcInstance.videoElement) {
329      return;
330    }
331  
332    const mediaError = vcInstance.videoElement.error;
333  
334    const eventData = VideoEventData.createErrorData(mediaError);
335  
336    vcInstance.events.publish(VideoEvents.PLAYBACK_ERROR, eventData);
337  
338    vcInstance.playbackState.isPlaying = false;
339  }
340  
341  export function handleServiceWorkerError(vcInstance, statusCode) {
342    if (!vcInstance || !vcInstance.videoElement) {
343      return;
344    }
345  
346    if (vcInstance.currentCid) {
347      console.warn(`HTTP error ${statusCode} for CID ${vcInstance.currentCid?.slice(0, 8) ?? 'N/A'}`);
348  
349      const httpError = new Error(`HTTP error ${statusCode}`);
350      httpError.code = statusCode;
351      httpError.name = 'HttpError';
352    }
353  }
354  
355  export function _handleSeekingStart(vcInstance) {
356    if (!vcInstance || !vcInstance.videoElement) {
357      return;
358    }
359  
360    if (!vcInstance.playbackState.isSeeking) {
361      vcInstance.playbackState.isSeeking = true;
362    }
363  }
364  
365  export function _handleSeekingEnd(vcInstance) {
366    if (!vcInstance || !vcInstance.videoElement) {
367      return;
368    }
369  
370    if (vcInstance.playbackState.isSeeking) {
371      vcInstance.playbackState.isSeeking = false;
372      vcInstance.playbackState.currentTimeSeconds = vcInstance.videoElement.currentTime;
373  
374      vcInstance.playbackState.isPlaying = !vcInstance.videoElement.paused;
375    }
376  }
377  
378  export function _handleMetadataLoaded(vcInstance) {
379    if (!vcInstance || !vcInstance.videoElement) {
380      return;
381    }
382  
383    _handleDurationChange(vcInstance);
384  
385    if (!Number.isFinite(vcInstance.videoElement.duration) || vcInstance.videoElement.duration <= 0) {
386      setTimeout(() => {
387        if (vcInstance.videoElement) {
388          _handleDurationChange(vcInstance);
389        }
390      }, 500);
391      setTimeout(() => {
392        if (vcInstance.videoElement) {
393          _handleDurationChange(vcInstance);
394        }
395      }, 1500);
396    }
397  }
398  
399  export function _handleWaiting(vcInstance) {
400    if (!vcInstance || !vcInstance.videoElement) {
401      return;
402    }
403  
404    const { videoElement, currentCid } = vcInstance;
405    const cidStr = currentCid?.slice(0, 8) || 'unknown';
406  
407    vcInstance.playbackState.isBuffering = true;
408  
409    const eventData = VideoEventData.createBufferingStartedData(currentCid);
410  
411    vcInstance.events.publish(VideoEvents.BUFFERING_STARTED, eventData);
412  
413    vcInstance._startStallDetectionTimeout();
414  }
415  
416  export function _handleStalled(vcInstance) {
417    if (!vcInstance || !vcInstance.videoElement) {
418      return;
419    }
420  
421    const { videoElement, currentCid } = vcInstance;
422    const cidStr = currentCid?.slice(0, 8) || 'unknown';
423  
424    logger.warn(`Video stalled event detected for CID ${cidStr}`);
425  
426    const videoEl = vcInstance.videoElement;
427    const diagnosticInfo = {
428      readyState: videoEl.readyState,
429      networkState: videoEl.networkState,
430      paused: videoEl.paused,
431      currentTime: videoEl.currentTime,
432      duration: videoEl.duration,
433      buffered: _getBufferedRangesInfo(videoEl.buffered),
434      currentSrc: videoEl.currentSrc,
435      error: videoEl.error
436        ? {
437            code: videoEl.error.code,
438            message: videoEl.error.message,
439          }
440        : null,
441      sources: Array.from(videoEl.querySelectorAll('source')).map(src => ({
442        src: src.src,
443        type: src.type,
444        provider: src.dataset.provider,
445      })),
446    };
447  
448    logger.info(`Stall diagnostic info for CID ${cidStr}:`, diagnosticInfo);
449  
450    vcInstance.playbackState.isBuffering = true;
451  
452    const eventData = VideoEventData.createBufferingStartedData(currentCid);
453  
454    vcInstance.events.publish(VideoEvents.BUFFERING_STARTED, eventData);
455  
456    vcInstance._startStallDetectionTimeout();
457  }
458  
459  function _getBufferedRangesInfo(buffered) {
460    if (!buffered) {
461      return [];
462    }
463  
464    const ranges = [];
465    for (let i = 0; i < buffered.length; i++) {
466      ranges.push({
467        start: buffered.start(i),
468        end: buffered.end(i),
469      });
470    }
471    return ranges;
472  }
473  
474  export function _handleCanPlay(vcInstance) {
475    if (!vcInstance || !vcInstance.videoElement) {
476      return;
477    }
478  
479    if (vcInstance.playbackState.isBuffering) {
480      vcInstance.playbackState.isBuffering = false;
481  
482      vcInstance._clearStallDetectionTimeout();
483  
484      const eventData = VideoEventData.createBufferingEndedData(vcInstance.currentCid);
485  
486      vcInstance.events.publish(VideoEvents.BUFFERING_ENDED, eventData);
487    }
488  }
489  
490  export function _handlePlaying(vcInstance) {
491    if (!vcInstance || !vcInstance.videoElement) {
492      return;
493    }
494  
495    const wasBuffering = vcInstance.playbackState.isBuffering;
496  
497    vcInstance.playbackState.isPlaying = true;
498    vcInstance.playbackState.isBuffering = false;
499  
500    vcInstance._clearStallDetectionTimeout();
501  
502    if (wasBuffering) {
503      const bufferingEndedData = VideoEventData.createBufferingEndedData(vcInstance.currentCid);
504      vcInstance.events.publish(VideoEvents.BUFFERING_ENDED, bufferingEndedData);
505    }
506  
507    const playingData = VideoEventData.createPlayingData(vcInstance.videoElement.currentTime);
508    vcInstance.events.publish(VideoEvents.PLAYING, playingData);
509  }