UNPKG

vp-outstream-player

Version:

Outstream video player with Google IMA integration

373 lines (318 loc) 9.98 kB
import { PlayerCallbacks, VideoConfig } from "../config/types"; import { loadHLSScript } from "./utility"; /** * This service class is in charge of: * - Initializing the video element for playback * - Setting up HLS.js if needed * - Handling playback controls and events */ export class VideoService { private container: HTMLElement; private callbacks: PlayerCallbacks; private videoElement: HTMLVideoElement | null = null; private hlsInstance: any = null; private playing: boolean = false; private videoConfig: VideoConfig; private started: boolean = false; constructor(container: HTMLElement, videoConfig: VideoConfig, callbacks: PlayerCallbacks) { this.container = container; this.videoConfig = videoConfig; this.callbacks = callbacks; } /** * Creates and initializes the video element with event listeners. */ private initializeVideoElement(): HTMLVideoElement { this.videoElement = this.container.querySelector(".vp-video") as HTMLVideoElement; if (this.videoElement) { this.setupVideoListeners(); return this.videoElement; } const video = document.createElement("video"); video.classList.add("vp-video"); video.playsInline = true; video.preload = "auto"; this.videoElement = video; this.setupVideoListeners(); this.container.appendChild(video); return video; } private setupVideoListeners(): void { const v = this.videoElement!; this.teardown.push( this.addListener(v, "play", () => this.handlePlay()), this.addListener(v, "pause", () => this.handlePause()), this.addListener(v, "timeupdate", () => this.handleTimeUpdate()), this.addListener(v, "ended", () => this.handleEnded()), this.addListener(v, "error", () => this.handleError(this.extractMediaError())), this.addListener(v, "click", (e) => this.handleClick(e)) ); } /** * Converts the current MediaError on the video element into an Error. */ private extractMediaError(): Error { const error = this.videoElement?.error; if (!error) { return new Error("Unknown video error"); } switch (error.code) { case MediaError.MEDIA_ERR_ABORTED: return new Error("Video playback aborted"); case MediaError.MEDIA_ERR_NETWORK: return new Error("Network error while loading video"); case MediaError.MEDIA_ERR_DECODE: return new Error("Video decoding error"); case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: return new Error("Video format not supported"); default: return new Error(`Unknown video error: ${error.message || error.code}`); } } /** * Loads a standard (non-HLS) video source. */ private loadStandardVideo(url: string): void { this.videoElement!.src = url; this.videoElement!.load(); } /** * Waits for video metadata to load. */ private async waitForMetadata(): Promise<void> { if (this.videoElement!.readyState > 0) return; return new Promise<void>((resolve) => { const video = this.videoElement!; const onLoaded = () => { video.removeEventListener("loadedmetadata", onLoaded); resolve(); }; video.addEventListener("loadedmetadata", onLoaded, { once: true }); }); } public async loadVideo(muted = true): Promise<void> { this.log("Loading video:", this.videoConfig.url, "muted:", muted); this.destroy(); // Clean up any previous instance // Initialize or get existing video element const video = this.initializeVideoElement(); // Apply settings video.muted = muted; video.volume = muted ? 0 : 1; video.loop = this.videoConfig.loop || false; // Notify listeners this.callbacks.onAdRequested?.(); // Validate URL const url = this.videoConfig.url; if (!url) { this.handleError(new Error("No video URL provided")); return; } const isHLS = url.includes(".m3u8"); this.log(`URL: ${url}, Format: ${isHLS ? "HLS" : "Standard video"}`); try { if (isHLS) { this.log("Setting up HLS playback"); await this.setupHLS(url); } else { this.log("Setting up standard video playback"); this.loadStandardVideo(url); } // Ensure metadata loaded before playing await this.waitForMetadata(); this.callbacks.onAdLoaded?.(); // For consistency } catch (err) { const message = err instanceof Error ? err.message : String(err); this.error("Video loading error:", message); this.handleError(err instanceof Error ? err : new Error(message)); } } private async setupHLS(url: string): Promise<void> { try { await loadHLSScript(); if (window.Hls && window.Hls.isSupported()) { this.log("HLS.js is supported, creating instance"); this.hlsInstance = new window.Hls(); return new Promise<void>((resolve, reject) => { // Add more detailed error handling this.hlsInstance.on(window.Hls.Events.ERROR, (_: any, data: any) => { this.warn("HLS Error:", data); // Only reject for fatal errors if (data.fatal) { const errorType = data.type; const errorDetails = data.details; const errorMessage = `HLS fatal error: ${errorType}, details: ${errorDetails}`; this.error(errorMessage); reject(new Error(errorMessage)); } }); // Setup manifest parsing event this.hlsInstance.on(window.Hls.Events.MANIFEST_PARSED, () => { this.log("HLS manifest parsed successfully, stream ready"); resolve(); }); // Load source and attach media if (this.videoElement) { this.log("Loading HLS source:", url); this.hlsInstance.loadSource(url); this.hlsInstance.attachMedia(this.videoElement); } else { reject(new Error("Video element not available")); } }); } else if ( this.videoElement && this.videoElement.canPlayType("application/vnd.apple.mpegurl") ) { // For Safari which has native HLS support this.log("Using native HLS support"); this.videoElement.src = url; } else { throw new Error("HLS is not supported in this browser and no native support available"); } } catch (err) { this.error("HLS initialization error:", err); throw new Error(`Failed to initialize HLS: ${err}`); } } private handlePlay(): void { this.playing = true; this.callbacks.onAdResume?.(); } private handlePause(): void { this.playing = false; this.callbacks.onAdPause?.(); } private handleTimeUpdate(): void { if (this.videoElement) { this.callbacks.onAdTimeUpdate?.(this.videoElement.currentTime); } } private handleEnded(): void { // If loop is true, the video will restart automatically // If not, we trigger the complete callback if (!this.videoConfig.loop) { this.callbacks.onAdComplete?.(); } } /** * Handles click events on the video element to toggle play/pause. */ private handleClick(e: Event): void { e.preventDefault(); this.playing ? this.pause() : this.play(); } private handleError(err: Error): void { this.error("Video playback error:", err); this.callbacks.onAdError?.(err); // For consistency } public async play(): Promise<void> { const video = this.videoElement; if (!video) return; try { // Ensure we have a source if (!video.src && !this.hlsInstance?.media) { throw new Error("No video source available"); } // Wait for metadata if needed if (video.readyState === 0) { await this.waitForMetadata(); } // Attempt playback await video.play(); this.log("Video playback started successfully"); this.playing = true; if (!this.started) { this.callbacks.onAdStarted?.(); this.started = true; } } catch (err: any) { const msg = String(err); this.error("Failed to play video:", msg); // Handle autoplay restrictions by retrying muted if (/(NotAllowedError|user gesture)/i.test(msg)) { this.warn("Unmuted play blocked, retrying muted"); video.muted = true; try { await this.play(); this.log("Video playing muted due to autoplay restrictions"); this.playing = true; } catch (muteErr) { this.error("Muted play attempt failed:", muteErr); throw muteErr; } } else { throw err; } } } public pause(): void { if (this.videoElement) { this.videoElement.pause(); this.playing = false; } } public setMuted(muted: boolean): void { if (this.videoElement) { this.videoElement.muted = muted; this.setVolume(muted ? 0 : 1); } } public setVolume(volume: number): void { if (this.videoElement) { this.videoElement.volume = volume; this.videoElement.muted = volume === 0; } } public skipAd(): void { // Skip to end of video if (this.videoElement) { this.videoElement.currentTime = this.videoElement.duration; this.callbacks.onAdSkipped?.(); } } get isPlaying(): boolean { return this.playing; } get isMuted(): boolean { return this.videoElement ? this.videoElement.muted : false; } get volume(): number { return this.videoElement ? this.videoElement.volume : 0; } get currentTime(): number { return this.videoElement ? this.videoElement.currentTime : 0; } get duration(): number { return this.videoElement ? this.videoElement.duration || 0 : 0; } private log(...args: any[]): void { if (this.videoConfig?.debug) { console.log("[VideoService]", ...args); } } private warn(...args: any[]): void { console.warn("[VideoService]", ...args); } private error(...args: any[]): void { console.error("[VideoService]", ...args); } private addListener = (el: EventTarget, type: string, fn: EventListener): (() => void) => { el.addEventListener(type, fn); return () => el.removeEventListener(type, fn); }; private teardown: (() => void)[] = []; // collect cleaners public destroy(): void { if (this.hlsInstance) { this.hlsInstance.destroy(); this.hlsInstance = null; } if (this.videoElement) { this.videoElement.removeAttribute("src"); this.videoElement.load(); this.videoElement.remove(); this.videoElement = null; } this.teardown.forEach((off) => off()); this.teardown.length = 0; } }