qrcode-reader-vue3
Version:
A set of Vue.js components for detecting and decoding QR codes.
187 lines (158 loc) • 5.84 kB
text/typescript
import {
StreamApiNotSupportedError,
InsecureContextError,
} from "./errors";
import { eventOn, timeout } from "./util";
import shimGetUserMedia from "./shimGetUserMedia";
export class Camera {
videoEl: HTMLVideoElement;
stream: MediaStream;
constructor(videoEl: HTMLVideoElement, stream: MediaStream) {
this.videoEl = videoEl;
this.stream = stream;
}
stop(): void {
this.videoEl.srcObject = null;
this.stream.getTracks().forEach((track) => {
this.stream.removeTrack(track);
track.stop();
});
}
getCapabilities(): MediaTrackCapabilities {
const [track] = this.stream.getVideoTracks();
// Firefox does not yet support getCapabilities as of August 2020
return track?.getCapabilities?.() ?? ({} as MediaTrackCapabilities);
}
}
type CameraMode = "auto" | "rear" | "front" | "off";
type LegacyVideoElement = HTMLVideoElement & {
mozSrcObject?: MediaStream;
};
type VideoElementWithOptionalSrcObject = HTMLVideoElement & {
srcObject?: MediaStream | null;
};
type ExtendedWindow = Window & {
webkitURL?: typeof window.URL;
};
type TorchCapabilities = MediaTrackCapabilities & {
torch?: boolean;
};
type TorchConstraintSet = MediaTrackConstraintSet & {
torch?: boolean;
};
const narrowDownFacingMode = async (
camera: CameraMode
): Promise<
| { deviceId: { exact: string } }
| { facingMode: { ideal: string } | { exact: string } }
| undefined
> => {
// Modern phones often have multipe front/rear cameras.
// Sometimes special purpose cameras like the wide-angle camera are picked
// by default. Those are not optimal for scanning QR codes but standard
// media constraints don't allow us to specify which camera we want exactly.
// However, explicitly picking the first entry in the list of all videoinput
// devices for as the default front camera and the last entry as the default
// rear camera seems to be a workaround.
const devices = (await navigator.mediaDevices.enumerateDevices()).filter(
({ kind }) => kind === "videoinput"
);
if (devices.length >= 2) {
const frontCamera = devices[0];
const rearCamera = devices[devices.length - 1];
switch (camera) {
case "auto":
return { deviceId: { exact: rearCamera.deviceId } };
case "rear":
return { deviceId: { exact: rearCamera.deviceId } };
case "front":
return { deviceId: { exact: frontCamera.deviceId } };
default:
return undefined;
}
} else {
switch (camera) {
case "auto":
return { facingMode: { ideal: "environment" } };
case "rear":
return { facingMode: { exact: "environment" } };
case "front":
return { facingMode: { exact: "user" } };
default:
return undefined;
}
}
};
export interface CameraOptions {
camera: CameraMode;
torch: boolean;
}
export default async function (
videoEl: HTMLVideoElement,
{ camera, torch }: CameraOptions
): Promise<Camera> {
// At least in Chrome `navigator.mediaDevices` is undefined when the page is
// loaded using HTTP rather than HTTPS. Thus `STREAM_API_NOT_SUPPORTED` is
// initialized with `false` although the API might actually be supported.
// So although `getUserMedia` already should have a built-in mechanism to
// detect insecure context (by throwing `NotAllowedError`), we have to do a
// manual check before even calling `getUserMedia`.
if (window.isSecureContext !== true) {
throw new InsecureContextError();
}
if (navigator?.mediaDevices?.getUserMedia === undefined) {
throw new StreamApiNotSupportedError();
}
// This is a browser API only shim. It patches the global window object which
// is not available during SSR. So we lazily apply this shim at runtime.
await shimGetUserMedia();
const constraints: MediaStreamConstraints = {
audio: false,
video: {
width: { min: 360, ideal: 640, max: 1920 },
height: { min: 240, ideal: 480, max: 1080 },
...(await narrowDownFacingMode(camera)),
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const videoWithMoz = videoEl as LegacyVideoElement;
const videoWithSrcObject = videoEl as VideoElementWithOptionalSrcObject;
const windowWithWebkitURL = window as ExtendedWindow;
if (videoWithSrcObject.srcObject !== undefined) {
videoWithSrcObject.srcObject = stream;
} else if (videoWithMoz.mozSrcObject !== undefined) {
videoWithMoz.mozSrcObject = stream;
} else if (typeof window.URL.createObjectURL === "function") {
const objectUrl = window.URL.createObjectURL(
stream as unknown as MediaSource
);
videoWithMoz.src = objectUrl;
} else if (
windowWithWebkitURL.webkitURL !== undefined &&
typeof windowWithWebkitURL.webkitURL.createObjectURL === "function"
) {
const objectUrl = windowWithWebkitURL.webkitURL.createObjectURL(
stream as unknown as MediaSource
);
videoWithMoz.src = objectUrl;
} else {
videoWithMoz.src = stream as unknown as string;
}
await eventOn(videoEl, "loadeddata");
// According to: https://oberhofer.co/mediastreamtrack-and-its-capabilities/#queryingcapabilities
// On some devices, getCapabilities only returns a non-empty object after
// some delay. There is no appropriate event so we have to add a constant timeout
await timeout(500);
if (torch) {
const [track] = stream.getVideoTracks();
const capabilities = track.getCapabilities();
const torchCapabilities = capabilities as TorchCapabilities;
if (torchCapabilities.torch) {
const torchConstraint: TorchConstraintSet = { torch: true };
track.applyConstraints({ advanced: [torchConstraint] });
} else {
console.warn("device does not support torch capability");
}
}
return new Camera(videoEl, stream);
}