vp-outstream-player
Version:
Outstream video player with Google IMA integration
373 lines (318 loc) • 9.98 kB
text/typescript
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;
}
}