core-impl.ts 9.61 KB
/**
 * @fileoverview
 * Core camera library implementations.
 * 
 * @author mebjas <minhazav@gmail.com>
 */

import {
    Camera,
    CameraCapabilities,
    CameraCapability,
    RangeCameraCapability,
    CameraRenderingOptions,
    RenderedCamera,
    RenderingCallbacks,
    BooleanCameraCapability
} from "./core";

/** Interface for a range value. */
interface RangeValue {
    min: number;
    max: number;
    step: number;
}

/** Abstract camera capability class. */
abstract class AbstractCameraCapability<T> implements CameraCapability<T> {
    protected readonly name: string;
    protected readonly track: MediaStreamTrack;

    constructor(name: string, track: MediaStreamTrack) {
        this.name = name;
        this.track = track;
    }

    public isSupported(): boolean {
        // TODO(minhazav): Figure out fallback for getCapabilities()
        // in firefox.
        // https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API/Constraints
        if (!this.track.getCapabilities) {
            return false;
        }
        return this.name in this.track.getCapabilities();
    }

    public apply(value: T): Promise<void> {
        let constraint: any = {};
        constraint[this.name] = value;
        let constraints = { advanced: [ constraint ] };
        return this.track.applyConstraints(constraints);
    }

    public value(): T | null {
        let settings: any = this.track.getSettings();
        if (this.name in settings) {
            let settingValue = settings[this.name];
            return settingValue;
        }

        return null;
    }
}

abstract class AbstractRangeCameraCapability extends AbstractCameraCapability<number> {
    constructor(name: string, track: MediaStreamTrack) {
       super(name, track);
    }

    public min(): number {
        return this.getCapabilities().min;
    }

    public max(): number {
        return this.getCapabilities().max;
    }

    public step(): number {
        return this.getCapabilities().step;
    }

    public apply(value: number): Promise<void> {
        let constraint: any = {};
        constraint[this.name] = value;
        let constraints = {advanced: [ constraint ]};
        return this.track.applyConstraints(constraints);
    }

    private getCapabilities(): RangeValue {
        this.failIfNotSupported();
        let capabilities: any = this.track.getCapabilities();
        let capability: any = capabilities[this.name];
        return {
            min: capability.min,
            max: capability.max,
            step: capability.step,
        };
    }

    private failIfNotSupported() {
        if (!this.isSupported()) {
            throw new Error(`${this.name} capability not supported`);
        }
    }
}

/** Zoom feature. */
class ZoomFeatureImpl extends AbstractRangeCameraCapability {
    constructor(track: MediaStreamTrack) {
        super("zoom", track);
    }
}

/** Torch feature. */
class TorchFeatureImpl extends AbstractCameraCapability<boolean> {
    constructor(track: MediaStreamTrack) {
        super("torch", track);
    }
}

/** Implementation of {@link CameraCapabilities}. */
class CameraCapabilitiesImpl implements CameraCapabilities {
    private readonly track: MediaStreamTrack;
    
    constructor(track: MediaStreamTrack) {
        this.track = track;
    }

    zoomFeature(): RangeCameraCapability {
        return new ZoomFeatureImpl(this.track);
    }

    torchFeature(): BooleanCameraCapability {
        return new TorchFeatureImpl(this.track);
    }
}

/** Implementation of {@link RenderedCamera}. */
class RenderedCameraImpl implements RenderedCamera {

    private readonly parentElement: HTMLElement;
    private readonly mediaStream: MediaStream;
    private readonly surface: HTMLVideoElement;
    private readonly callbacks: RenderingCallbacks;

    private isClosed: boolean = false;

    private constructor(
        parentElement: HTMLElement,
        mediaStream: MediaStream,
        callbacks: RenderingCallbacks) {
        this.parentElement = parentElement;
        this.mediaStream = mediaStream;
        this.callbacks = callbacks;

        this.surface = this.createVideoElement(this.parentElement.clientWidth);

        // Setup
        parentElement.append(this.surface);
    }

    private createVideoElement(width: number): HTMLVideoElement {
        const videoElement = document.createElement("video");
        videoElement.style.width = `${width}px`;
        videoElement.style.display = "block";
        videoElement.muted = true;
        videoElement.setAttribute("muted", "true");
        (<any>videoElement).playsInline = true;
        return videoElement;
    }

    private setupSurface() {
        this.surface.onabort = () => {
            throw "RenderedCameraImpl video surface onabort() called";
        };

        this.surface.onerror = () => {
            throw "RenderedCameraImpl video surface onerror() called";
        };

        let onVideoStart = () => {
            const videoWidth = this.surface.clientWidth;
            const videoHeight = this.surface.clientHeight;
            this.callbacks.onRenderSurfaceReady(videoWidth, videoHeight);
            this.surface.removeEventListener("playing", onVideoStart);
        };

        this.surface.addEventListener("playing", onVideoStart);
        this.surface.srcObject = this.mediaStream;
        this.surface.play();
    }

    static async create(
        parentElement: HTMLElement,
        mediaStream: MediaStream,
        options: CameraRenderingOptions,
        callbacks: RenderingCallbacks)
        : Promise<RenderedCamera> {
        let renderedCamera = new RenderedCameraImpl(
            parentElement, mediaStream, callbacks);
        if (options.aspectRatio) {
            let aspectRatioConstraint = {
                aspectRatio: options.aspectRatio!
            };
            await renderedCamera.getFirstTrackOrFail().applyConstraints(
                aspectRatioConstraint);
        }

       renderedCamera.setupSurface();
        return renderedCamera;
    }

    private failIfClosed() {
        if (this.isClosed) {
            throw "The RenderedCamera has already been closed.";
        }
    }

    private getFirstTrackOrFail(): MediaStreamTrack {
        this.failIfClosed();

        if (this.mediaStream.getVideoTracks().length === 0) {
            throw "No video tracks found";
        }

        return this.mediaStream.getVideoTracks()[0];
    }

    //#region Public APIs.
    public pause(): void {
        this.failIfClosed();
        this.surface.pause();
    }

    public resume(onResumeCallback: () => void): void {
        this.failIfClosed();
        let $this = this;

        const onVideoResume = () => {
            // Transition after 200ms to avoid the previous canvas frame being
            // re-scanned.
            setTimeout(onResumeCallback, 200);
            $this.surface.removeEventListener("playing", onVideoResume);
        };

        this.surface.addEventListener("playing", onVideoResume);
        this.surface.play();
    }

    public isPaused(): boolean {
        this.failIfClosed();
        return this.surface.paused;
    }

    public getSurface(): HTMLVideoElement {
        this.failIfClosed();
        return this.surface;
    }

    public getRunningTrackCapabilities(): MediaTrackCapabilities {
        return this.getFirstTrackOrFail().getCapabilities();
    }

    public getRunningTrackSettings(): MediaTrackSettings {
        return this.getFirstTrackOrFail().getSettings();
    }

    public async applyVideoConstraints(constraints: MediaTrackConstraints)
        : Promise<void> {
        if ("aspectRatio" in constraints) {
            throw "Changing 'aspectRatio' in run-time is not yet supported.";
        }

        return this.getFirstTrackOrFail().applyConstraints(constraints);
    }

    public close(): Promise<void> {
        if (this.isClosed) {
            // Already closed.
            return Promise.resolve();
        }

        let $this = this;
        return new Promise((resolve, _) => {
            let tracks = $this.mediaStream.getVideoTracks();
            const tracksToClose = tracks.length;
            var tracksClosed = 0;
            $this.mediaStream.getVideoTracks().forEach((videoTrack) => {
                $this.mediaStream.removeTrack(videoTrack);
                videoTrack.stop();
                ++tracksClosed;
    
                if (tracksClosed >= tracksToClose) {
                    $this.isClosed = true;
                    $this.parentElement.removeChild($this.surface);
                    resolve();
                }
            });
    
            
        });
    }

    getCapabilities(): CameraCapabilities {
        return new CameraCapabilitiesImpl(this.getFirstTrackOrFail());
    }
    //#endregion
}

/** Default implementation of {@link Camera} interface. */
export class CameraImpl implements Camera {
    private readonly mediaStream: MediaStream;

    private constructor(mediaStream: MediaStream) {
        this.mediaStream = mediaStream;
    }

    async render(
        parentElement: HTMLElement,
        options: CameraRenderingOptions,
        callbacks: RenderingCallbacks)
        : Promise<RenderedCamera> {
        return RenderedCameraImpl.create(
            parentElement, this.mediaStream, options, callbacks);
    }

    static async create(videoConstraints: MediaTrackConstraints)
        : Promise<Camera> {
        if (!navigator.mediaDevices) {
            throw "navigator.mediaDevices not supported";
        }
        let constraints: MediaStreamConstraints = {
            audio: false,
            video: videoConstraints
        };

        let mediaStream = await navigator.mediaDevices.getUserMedia(
            constraints);
        return new CameraImpl(mediaStream);
    }
}