video-buffer-tracker
Version:
Professional video buffering analytics & tracking library for monitoring video streaming performance, buffering progress, and user experience in real-time. Perfect for video platforms, e-learning apps, and media analytics.
2 lines (1 loc) • 11.1 kB
JavaScript
"use strict";class e{static validateVideoElement(e){if(!e)throw new Error("Video element is required");if(!(e instanceof HTMLVideoElement))throw new Error("Element must be an HTMLVideoElement")}static validateVideoUrl(e){if(!e||"string"!=typeof e)throw new Error("Video URL is required and must be a string");try{new URL(e)}catch(e){throw new Error("Invalid video URL format")}}static validateConfig(e){if(void 0!==e.progressThrottleMs&&e.progressThrottleMs<0)throw new Error("progressThrottleMs must be non-negative");if(void 0!==e.finalProbeThreshold&&e.finalProbeThreshold<0)throw new Error("finalProbeThreshold must be non-negative");if(void 0!==e.rangeMergeEpsilon&&e.rangeMergeEpsilon<0)throw new Error("rangeMergeEpsilon must be non-negative");if(e.trafficEventUrl)try{new URL(e.trafficEventUrl)}catch(e){throw new Error("Invalid trafficEventUrl format")}}static validateVideoInfo(e){if(!e.url)throw new Error("Video URL is required");if(e.duration<=0)throw new Error("Video duration must be positive");if(e.totalSize<=0)throw new Error("Video total size must be positive");this.validateVideoElement(e.element)}}class t{constructor(e=!1){this.isDebugEnabled=e}debug(e,...t){this.isDebugEnabled}info(e,...t){}warn(e,...t){}error(e,...t){}}class i{constructor(e){this.cache=new Map,this.logger=e}async getVideoSize(e){if(this.cache.has(e))return this.logger.debug("Using cached video size for:",e),this.cache.get(e);try{this.logger.debug("Fetching video size for:",e);const t=await fetch(e,{method:"HEAD",signal:AbortSignal.timeout(1e4)});if(!t.ok)throw new Error(`HTTP ${t.status}: ${t.statusText}`);const i=t.headers.get("Content-Length");if(!i)throw new Error("Content-Length header not found");const r=parseInt(i,10);if(isNaN(r)||r<=0)throw new Error("Invalid Content-Length value");return this.cache.set(e,r),this.logger.debug("Video size detected:",r,"bytes"),r}catch(e){throw this.logger.error("Failed to get video size:",e),new Error(`Failed to get video size: ${e instanceof Error?e.message:"Unknown error"}`)}}clearCache(){this.cache.clear(),this.logger.debug("Video size cache cleared")}removeFromCache(e){this.cache.delete(e),this.logger.debug("Removed from cache:",e)}}class r{constructor(e){this.logger=e}getBufferedRanges(e){const t=[];try{const i=e.buffered;if(!i)return this.logger.debug("No buffered ranges available"),t;for(let e=0;e<i.length;e++)try{const r=i.start(e),s=i.end(e);r>=0&&s>r&&t.push({start:r,end:s})}catch(e){this.logger.warn("Error accessing buffered range:",e)}return this.logger.debug("Buffered ranges:",t),t}catch(e){return this.logger.error("Error getting buffered ranges:",e),t}}mergeRanges(e,t){if(0===e.length)return e;const i=[...e].sort((e,t)=>e.start-t.start),r=[];for(const e of i){if(0===r.length){r.push(e);continue}const i=r[r.length-1];e.start-i.end<=t?i.end=Math.max(i.end,e.end):r.push(e)}return this.logger.debug("Merged ranges:",r),r}isFullyDownloaded(e,t,i){if(0===e.length||t<=0)return!1;const r=this.mergeRanges(e,i);let s=0;for(const e of r)s+=Math.max(0,e.end-e.start);const o=s>=t-i;return this.logger.debug("Download completion check:",{covered:s,duration:t,isComplete:o}),o}timeToByteRange(e,t,i,r){if(!i||!r||e<0||t<=e)return null;const s=Math.max(0,Math.floor(e/i*r)),o=Math.min(r-1,Math.floor(t/i*r));return o<=s?null:{start:s,end:o}}calculateTotalBytes(e,t,i){let r=0;for(const s of e){const e=this.timeToByteRange(s.start,s.end,t,i);e&&(r+=e.end-e.start+1)}return this.logger.debug("Calculated total bytes:",r),r}}class s{constructor(e,t){this.logger=e,this.trafficEventUrl=t}async sendBufferData(e){if(!this.trafficEventUrl)return this.logger.debug("No analytics URL configured, skipping data submission"),!1;try{this.logger.debug("Sending buffer data to analytics:",e);const t=await fetch(this.trafficEventUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),signal:AbortSignal.timeout(5e3)});if(!t.ok)throw new Error(`HTTP ${t.status}: ${t.statusText}`);return this.logger.debug("Analytics data sent successfully"),!0}catch(e){return this.logger.error("Failed to send analytics data:",e),!1}}async callUserCallback(e,t){try{this.logger.debug("Calling user callback with data:",t),await e(t),this.logger.debug("User callback executed successfully")}catch(e){throw this.logger.error("Error in user callback:",e),e}}}class o{constructor(e,t,i){this.isAttached=!1,this.logger=e,this.video=t,this.eventHandlers=i}attach(){if(this.isAttached)this.logger.warn("Event listeners already attached");else try{this.video.addEventListener("progress",this.eventHandlers.onProgress),this.video.addEventListener("timeupdate",this.eventHandlers.onTimeUpdate),window.addEventListener("beforeunload",this.eventHandlers.onFinalize),window.addEventListener("pagehide",this.eventHandlers.onFinalize),document.addEventListener("visibilitychange",this.handleVisibilityChange.bind(this)),this.isAttached=!0,this.logger.debug("Event listeners attached successfully")}catch(e){throw this.logger.error("Failed to attach event listeners:",e),e}}detach(){if(this.isAttached)try{this.video.removeEventListener("progress",this.eventHandlers.onProgress),this.video.removeEventListener("timeupdate",this.eventHandlers.onTimeUpdate),window.removeEventListener("beforeunload",this.eventHandlers.onFinalize),window.removeEventListener("pagehide",this.eventHandlers.onFinalize),document.removeEventListener("visibilitychange",this.handleVisibilityChange.bind(this)),this.isAttached=!1,this.logger.debug("Event listeners detached successfully")}catch(e){this.logger.error("Failed to detach event listeners:",e)}else this.logger.debug("Event listeners not attached, skipping detach")}handleVisibilityChange(){"hidden"===document.visibilityState&&(this.logger.debug("Page hidden, triggering finalize"),this.eventHandlers.onFinalize())}areListenersAttached(){return this.isAttached}}exports.AnalyticsService=s,exports.BufferCalculationService=r,exports.DefaultLogger=t,exports.EventManager=o,exports.SilentLogger=class{debug(){}info(){}warn(){}error(){}},exports.ValidationUtils=e,exports.VideoBufferTracker=class{constructor(o={}){var n,a,l,d;this.estimatedDownloadedBytes=0,this.submittedBytesBaseline=0,this.hasSubmittedFullDownload=!1,this.lastProbedEnd=-1,this.isTracking=!1,e.validateConfig(o),this.config={trafficEventUrl:o.trafficEventUrl||void 0,onBufferData:o.onBufferData||void 0,debug:null!==(n=o.debug)&&void 0!==n&&n,progressThrottleMs:null!==(a=o.progressThrottleMs)&&void 0!==a?a:250,finalProbeThreshold:null!==(l=o.finalProbeThreshold)&&void 0!==l?l:.2,rangeMergeEpsilon:null!==(d=o.rangeMergeEpsilon)&&void 0!==d?d:.5},this.logger=new t(this.config.debug),this.videoSizeService=new i(this.logger),this.bufferService=new r(this.logger),this.analyticsService=new s(this.logger,this.config.trafficEventUrl),this.logger.info("VideoBufferTracker initialized")}async setupVideoTracking(t,i){try{e.validateVideoElement(t),e.validateVideoUrl(i),this.logger.info("Setting up video tracking",{videoUrl:i});const r=await this.videoSizeService.getVideoSize(i);this.videoInfo={url:i,duration:t.duration||0,totalSize:r,element:t},this.setupEventHandlers(),this.isTracking=!0,this.logger.info("Video tracking setup complete")}catch(e){throw this.logger.error("Failed to setup video tracking:",e),e}}getBufferData(){var e;return{file_size:Math.round(this.estimatedDownloadedBytes),video_url:null===(e=this.videoInfo)||void 0===e?void 0:e.url,timestamp:Date.now()}}getTrackingStats(){var e,t,i,r;return{totalSize:null!==(t=null===(e=this.videoInfo)||void 0===e?void 0:e.totalSize)&&void 0!==t?t:0,estimatedDownloadedBytes:this.estimatedDownloadedBytes,hasSubmittedFullDownload:this.hasSubmittedFullDownload,bufferedRanges:this.videoInfo?this.bufferService.getBufferedRanges(this.videoInfo.element):[],duration:null!==(r=null===(i=this.videoInfo)||void 0===i?void 0:i.duration)&&void 0!==r?r:0}}destroy(){try{this.logger.info("Destroying VideoBufferTracker"),this.eventManager&&this.eventManager.detach(),this.isTracking=!1,this.videoInfo=void 0,this.estimatedDownloadedBytes=0,this.submittedBytesBaseline=0,this.hasSubmittedFullDownload=!1,this.lastProbedEnd=-1,this.logger.info("VideoBufferTracker destroyed")}catch(e){this.logger.error("Error destroying VideoBufferTracker:",e)}}isTrackingActive(){return this.isTracking}setupEventHandlers(){if(!this.videoInfo)throw new Error("Video info not available");const e={onProgress:this.createProgressHandler(),onTimeUpdate:this.createTimeUpdateHandler(),onFinalize:this.createFinalizeHandler()};this.eventManager=new o(this.logger,this.videoInfo.element,e),this.eventManager.attach()}createProgressHandler(){return async()=>{if(!this.videoInfo||!this.isTracking||this.hasSubmittedFullDownload)return;const{element:e,duration:t,totalSize:i}=this.videoInfo;if(!t||!i)return;const r=this.bufferService.getBufferedRanges(e);0!==r.length&&(this.bufferService.isFullyDownloaded(r,t,this.config.rangeMergeEpsilon)?this.handleFullDownload():await this.updateProgressEstimate(r))}}createTimeUpdateHandler(){return async()=>{if(!this.videoInfo||!this.isTracking||this.hasSubmittedFullDownload)return;const{element:e,duration:t}=this.videoInfo;if(!t||isNaN(t))return;t-e.currentTime<=this.config.finalProbeThreshold&&(this.logger.debug("Video near end, triggering final probe"),await this.performFinalProbe())}}createFinalizeHandler(){return async()=>{if(this.isTracking&&!this.hasSubmittedFullDownload)try{await this.performFinalProbe()}catch(e){this.logger.error("Error in finalize handler:",e)}}}async updateProgressEstimate(e){if(!this.videoInfo)return;const{duration:t,totalSize:i}=this.videoInfo,{start:r,end:s}=e[e.length-1];if(this.lastProbedEnd>=0&&s-this.lastProbedEnd<this.config.progressThrottleMs/1e3)return;this.lastProbedEnd=s;const o=this.bufferService.calculateTotalBytes(e,t,i);o>0&&(this.estimatedDownloadedBytes=o,this.logger.debug("Progress updated:",{totalBytes:o}))}handleFullDownload(){this.videoInfo&&(this.estimatedDownloadedBytes=this.videoInfo.totalSize-this.submittedBytesBaseline,this.hasSubmittedFullDownload=!0,this.logger.info("Full download detected"),this.handleBufferData())}async performFinalProbe(){if(!this.videoInfo)return;const{element:e,duration:t,totalSize:i}=this.videoInfo;if(!t||!i)return;const r=this.bufferService.getBufferedRanges(e);if(0===r.length)return;const s=this.bufferService.calculateTotalBytes(r,t,i);this.updateDownloadMetrics(s)}updateDownloadMetrics(e){const t=Math.max(0,e-this.submittedBytesBaseline);this.estimatedDownloadedBytes=t,this.videoInfo&&e>=this.videoInfo.totalSize&&(this.hasSubmittedFullDownload=!0),this.handleBufferData(),this.submittedBytesBaseline=e}async handleBufferData(){if(this.estimatedDownloadedBytes<=0)return;const e=this.getBufferData();try{this.config.onBufferData&&await this.analyticsService.callUserCallback(this.config.onBufferData,e),await this.analyticsService.sendBufferData(e),this.estimatedDownloadedBytes=0,this.lastProbedEnd=-1,this.logger.debug("Buffer data handled successfully")}catch(e){this.logger.error("Error handling buffer data:",e)}}},exports.VideoSizeService=i;