Skip to content

Instantly share code, notes, and snippets.

@michaelNgiri
Forked from AngelMunoz/media.service.ts
Created November 15, 2023 07:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save michaelNgiri/6f8b9551ed08eb3f432f52f3576b18a9 to your computer and use it in GitHub Desktop.
Save michaelNgiri/6f8b9551ed08eb3f432f52f3576b18a9 to your computer and use it in GitHub Desktop.
a simple typescript class that deals with the HTML Media API for camera stuff
export interface ISwitchCameraArgs {
deviceId?: string
}
export interface IStartCameraArgs {
constraints?: MediaStreamConstraints;
retryCount?: number;
}
export interface ICameraDevice extends MediaDeviceInfo {
isFront?: boolean;
isBack?: boolean;
}
export enum CameraEventsEnum {
Take_Picture = 'on-take-picture',
Switch_Camera = 'on-switch-camera',
Cancel = 'on-cancel'
}
export interface ICameraEvents {
event: CameraEventsEnum;
args?: Record<string, any>
}
export interface ICameraError {
event: CameraEventsEnum;
message: string;
error: Error;
}
export class MediaService {
public readonly defaultMediaStreamConstraints: MediaStreamConstraints = {
video: true
};
private lastActiveCamera: string;
private cameras: Array<MediaDeviceInfo & { active?: boolean }> = [];
private canvas: HTMLCanvasElement;
/**
* @param {HTMLVideoElement} source existing video tag where to perform video operations
*/
constructor(private source: HTMLVideoElement) {
this.canvas = document.createElement("canvas");
}
get supportsUserMedia() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
get supportsEnumerateDevices() {
return !!(
navigator.mediaDevices && navigator.mediaDevices.enumerateDevices
);
}
get hasMultipleCameras() {
return this.cameras.length > 1;
}
private delay(timeout = 100) {
return new Promise<void>(resolve => setTimeout(() => resolve(), timeout));
}
/**
* gets the stream of the current video source
* @returns {MediaStream}
*/
getStream(): MediaStream {
return (
this.source.srcObject instanceof MediaStream && this.source.srcObject
);
}
/**
* tries to get the active video tracks from the current screen.
* helps to determine which camera not to use when switching cameras
*/
getActiveVideoTracks() {
const stream = this.getStream();
if (!stream) return [];
const tracks = stream.getVideoTracks();
return tracks.filter(track => track.enabled);
}
/**
* from the current cameras registered on the class filters the ones not in use
*/
getInactiveCameras() {
return this.cameras.filter(camera => !camera.active);
}
/**
* tries to start a stream to trigger the browser's permission dialog
* once the permission is given it stop the tracks of the stream
* @returns {Promise<boolean>}
*/
async requestPermission(): Promise<boolean> {
if (!this.supportsUserMedia)
throw new Error("The Browser does not support getUserMedia");
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
for (const track of stream.getTracks()) {
track.stop();
}
return true;
} catch (error) {
console.warn(error.message);
return false;
}
}
/**
* gets the video devices returned as "videoinput" from `navigator.mediaDevices.enumerateDevices()`
*/
async getVideoDevices() {
if (!this.supportsEnumerateDevices)
throw new Error("The browser does not support enumerateDevices");
try {
const devices: MediaDeviceInfo[] = await navigator.mediaDevices.enumerateDevices();
return devices
.filter(device => device.kind == "videoinput")
.map(device => {
const isFront = this.checkIsFront(device);
const isBack = this.checkIsBack(device);
return { ...device, isFront, isBack } as ICameraDevice;
});
} catch (error) {
return Promise.reject(error);
}
}
/**
* Starts the camera and streams the content into the provided video element from the **args**
* if no video and constraints are provided, this method will use it's defaults from the class
* @param {IStartCameraArgs} args arguments needed to start the camera
*/
async startCamera({
constraints = this.defaultMediaStreamConstraints,
retryCount = 10
}: IStartCameraArgs = {}): Promise<void> {
if (!this.supportsUserMedia)
throw new Error("The Browser does not support getUserMedia");
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
this.source.srcObject = stream;
} catch (error) {
if (retryCount > 0) {
console.warn(`Error ${error.message}... retrying once more`);
await this.delay(500);
return this.startCamera({ constraints, retryCount: retryCount - 1 });
}
return Promise.reject(error);
}
return this.setActiveCamera();
}
/**
* stops the current camera stream and removes the **srcObject** from the given video element
* if not video element provided, the default from the class will be used, a deviceId
* can be passed to switch to a specific camera
* @param {ISwitchCameraArgs} args
*/
async switchCamera({ deviceId }: ISwitchCameraArgs = {}): Promise<void> {
try {
await this.stopCamera();
} catch (error) {
return Promise.reject(error);
}
try {
if (deviceId) {
await this.startCamera({
constraints: { video: { deviceId: { exact: deviceId } } }
});
} else {
const inactive = this.getInactiveCameras();
const [camera] = inactive.filter(
camera => camera.label !== this.lastActiveCamera
);
await this.startCamera({
constraints: { video: { deviceId: { exact: camera.deviceId } } }
});
}
} catch (error) {
return Promise.reject(error);
}
}
async stopCamera() {
this.source.pause();
const stream = this.getStream();
if (!stream) return;
const tracks = stream.getTracks();
for (const track of tracks) {
track.stop();
}
this.source.srcObject = null;
}
async takeScreenshot(
asFile = false,
mimeType = "image/webp"
): Promise<string | File> {
this.canvas.width = this.source.videoWidth;
this.canvas.height = this.source.videoHeight;
this.canvas.getContext("2d").drawImage(this.source, 0, 0);
const url = this.canvas.toDataURL(mimeType);
if (!asFile) {
return url;
}
const ext = mimeType.split("/").pop();
const namelike = this.getDateLikeStr(new Date());
return this.base64ToFile(url, `${namelike}`, mimeType);
}
/**
* from the current stream pick the first active video track
* then get the enumeratedDevices and add flag to it
*/
private async setActiveCamera(): Promise<void> {
const [activeTrack] = this.getActiveVideoTracks();
try {
this.cameras = await this.getVideoDevices();
} catch (error) {
return Promise.reject(error);
}
if (!activeTrack) {
this.cameras = this.cameras.map(camera => {
camera.active = false;
return camera;
});
} else {
this.lastActiveCamera = activeTrack && activeTrack.label;
this.cameras = this.cameras.map(camera => {
camera.active = camera.label === activeTrack.label;
return camera;
});
}
}
private base64ToFile(
url: string,
name: string,
mimeType = "image/webp"
): Promise<File> {
return fetch(url)
.then(res => res.arrayBuffer())
.then(buffer => new File([buffer], name, { type: mimeType }));
}
private getDateLikeStr(now: Date) {
return `${now.getFullYear()}-${`${now.getMonth() + 1}`.padStart(
2,
"0"
)}-${`${now.getDate()}`.padStart(2, "0")}-${`${now.getHours()}`.padStart(
2,
"0"
)}${`${now.getUTCMinutes()}`.padStart(
2,
"0"
)}${`${now.getMilliseconds()}`.padStart(2, "0").slice(0, 2)}`;
}
private checkIsBack(device: MediaDeviceInfo) {
const isBack = device.label.includes("back");
const isRear = device.label.includes("rear");
const isEnvironment = device.label.includes("environment");
const isSecond = device.label.includes("1");
return isBack || isRear || isEnvironment || isSecond;
}
private checkIsFront(device: MediaDeviceInfo) {
const isFront = device.label.includes("front");
const isFacing = device.label.includes("facing");
const isUser = device.label.includes("user");
const isFirst = device.label.includes("0");
return isFront || isFacing || isUser || isFirst;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment