封装
// 请在以下环境调试使用媒体流功能:
// localhost域 || HTTPS协议域名 || file:///协议本地文件
// 其他情况,navigator.mediaDevices 的值或为 undefined。
// chrome调试参考 https://blog.csdn.net/weixin_49293345/article/details/112600213?spm=1001.2014.3001.5501
import fixWebmDuration from "webm-duration-fix";
declare global {
interface Navigator {
webkitGetUserMedia: any;
mozGetUserMedia: any;
}
}
class NewNavigator extends Navigator {
mediaDevices: any;
}
export enum ErrorCode {
INITE_ERROR = 100, // 初始化失败
STARTRECORD_ERROR = 101, // 开始录制错误
STOPRECORD_ERROR = 102, // 结束录制错误
}
export interface RecordConfig {
front?: boolean; // 控制前后摄像头
audio?: boolean; // 是否录制音频
width?: number; // 获取一个最接近 width*height 的相机分辨率
height?: number;
}
export interface ErrorData {
code: ErrorCode;
message: string;
}
export type ErrorCallback = (error: ErrorData) => void;
export interface GetUserMediaConfig {
audio: boolean;
video: {
width?: { min?: number; ideal?: number; max?: number };
height?: { min?: number; ideal?: number; max?: number };
facingMode: "user" | { exact: "environment" };
};
}
export type StopRecordCallback = (data: {
videoBlob: Blob;
imageUrl: string;
}) => void;
function createVideoEl(): HTMLVideoElement {
const videoEl = document.createElement("video");
videoEl.style.width = "100%";
videoEl.style.height = "100%";
videoEl.style.objectFit = "cover";
videoEl.style.position = "absolute";
videoEl.style.left = "0";
videoEl.style.top = "0";
return videoEl;
}
const navigator: NewNavigator = window.navigator;
// 初始化媒体流方法
const initMediaDevices = function () {
// 老的浏览器可能根本没有实现 mediaDevices,所以我们可以先设置一个空的对象
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
// 一些浏览器部分支持 mediaDevices。我们不能直接给对象设置 getUserMedia
// 因为这样可能会覆盖已有的属性。这里我们只会在没有getUserMedia属性的时候添加它。
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = function (
constraints: GetUserMediaConfig
) {
// 首先,如果有getUserMedia的话,就获得它
const getUserMedia =
navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
// 一些浏览器根本没实现它 - 那么就返回一个error到promise的reject来保持一个统一的接口
if (!getUserMedia) {
return Promise.reject(
new Error("getUserMedia is not implemented in this browser")
);
}
// 否则,为老的navigator.getUserMedia方法包裹一个Promise
return new Promise(function (resolve, reject) {
getUserMedia.call(navigator, constraints, resolve, reject);
});
};
}
};
let video_timer: any = null;
const debounce = (fun: () => void, time: number) => {
if (video_timer) {
clearTimeout(video_timer);
}
video_timer = setTimeout(() => {
fun();
video_timer = null;
}, time);
};
class RecordJs {
private readonly container: HTMLElement;
private readonly videoEl: HTMLVideoElement;
private recording: boolean; // 是否正在录制
private mediaStream: any; // 记录媒体流
private mediaRecorder: any; // 记录媒体录制流
private recorderFile: Blob; // 录制到的文件
private imageUrl: string; // 截取到的图片
private stopRecordCallback?: StopRecordCallback; // 录制完回调
private errorCallback?: ErrorCallback; // 错误回调
constructor(container: HTMLElement, errorCallback?: ErrorCallback) {
this.container = container;
this.recording = false;
this.mediaStream = null;
this.mediaRecorder = null;
this.recorderFile = new Blob();
this.imageUrl = "";
this.stopRecordCallback = undefined;
this.errorCallback = errorCallback;
this.container.style.position = "relative";
this.videoEl = createVideoEl();
initMediaDevices();
}
// 截取最后一帧显示
public cropImage(): string {
const canvas = document.createElement("canvas");
canvas.width = this.container.clientWidth;
canvas.height = this.container.clientHeight;
canvas
.getContext("2d")
?.drawImage(this.videoEl, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/png") || "";
this.imageUrl = dataUrl;
return dataUrl;
}
// 调用Api打开媒体流
private _getUserMedia(recordConfig?: RecordConfig) {
const { front = true, width, height, audio } = recordConfig || {};
const constraints = {
video: {
facingMode: front ? "user" : { exact: "environment" },
width: { ideal: width },
height: { ideal: height },
},
audio,
};
navigator.mediaDevices
.getUserMedia(constraints)
.then((stream: any) => {
// 先清除存在的媒体流
this.closeRecord();
// 初始化媒体流文件 ---start
let chunks: any = [];
this.mediaStream = stream;
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (e: BlobEvent) => {
this.mediaRecorder.blobs.push(e.data);
chunks.push(e.data);
};
this.mediaRecorder.blobs = [];
this.mediaRecorder.onstop = async () => {
this.recorderFile = new Blob(chunks, {
type: this.mediaRecorder.mimeType,
});
// 处理录制视频播放获取时长问题
try {
const fixBlob = await fixWebmDuration(this.recorderFile);
this.recorderFile = fixBlob;
} catch (error) {
console.log(error);
}
chunks = [];
this.recording = false;
if (this.stopRecordCallback) {
try {
this.stopRecordCallback({
videoBlob: this.recorderFile,
imageUrl: this.imageUrl,
});
} catch (error: unknown) {
console.log("录制完业务回调出错了:" + error);
}
}
};
// 初始化媒体流文件 ---end
// 初始化video播放内容 ---start
// 旧的浏览器可能没有srcObject
const videoEl = this.videoEl;
// 前置摄像头左右视觉效果对调
videoEl.style.transform = front ? "rotateY(180deg)" : "";
if ("srcObject" in videoEl) {
videoEl.srcObject = stream;
} else {
// 防止在新的浏览器里使用它,应为它已经不再支持了
(videoEl as HTMLVideoElement).src =
window.URL.createObjectURL(stream);
}
if (this.container.lastChild?.nodeName === "VIDEO") {
this.container.removeChild(this.container.lastChild);
}
this.container.appendChild(videoEl);
videoEl.onloadedmetadata = () => {
// 这里处理了一点关于ios video.play()播放NotAllowedError问题
const playPromise = videoEl.play();
this.container.appendChild(videoEl);
if (playPromise !== undefined) {
playPromise
.then(() => {
videoEl.muted = true;
setTimeout(() => {
videoEl.play();
}, 10);
})
.catch((error) => {
console.log(error);
});
}
};
// 初始化video播放内容 ---end
})
.catch((error: Error) => {
this.errorCallback &&
this.errorCallback({
code: ErrorCode.INITE_ERROR,
message: error.message,
});
console.log(error);
});
}
// 打开媒体流
public openRecord(recordConfig?: RecordConfig) {
// 打开媒体流方法为异步 防止频繁切换前后摄像头调用媒体流非正常显示问题
debounce(() => this._getUserMedia(recordConfig), 300);
}
// 开始录制
public startRecord() {
if (this.recording) {
return;
}
this.recording = true;
try {
this.mediaRecorder?.start();
} catch (error) {
this.errorCallback &&
this.errorCallback({
code: ErrorCode.STARTRECORD_ERROR,
message: "开始录制出错了:" + error,
});
console.log("开始录制出错了:" + error);
}
}
// 停止录制
public stopRecord(callback?: StopRecordCallback) {
if (this.recording && this.mediaStream) {
this.stopRecordCallback = callback;
this.cropImage();
// 终止录制器
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
try {
this.mediaRecorder.stop();
} catch (error) {
this.errorCallback &&
this.errorCallback({
code: ErrorCode.STOPRECORD_ERROR,
message: "结束录制出错了:" + error,
});
console.log("结束录制出错了:" + error);
}
}
}
}
// 关闭媒体流
public closeRecord() {
this.stopRecord();
if (!this.mediaStream) return;
const tracks = (this.mediaStream as MediaStream).getTracks();
tracks.forEach((track) => {
if (typeof track.stop === "function") {
track.stop();
}
});
}
}
export default RecordJs;
使用
import RecordJs from './RecordJs' // 引入 RecordJs
const recordJs = new RecordJs(Dom元素, 错误回调函数) // 初始化 给定一个dom作为容器
recordJs.openRecord({ front: true }) // 开启前摄像头
recordJs.startRecord(); // 开始录制
recordJs.stopRecord((data)=>{
// 传入结束录制回调
// data对象返回视频blob和截取的最后一帧图片url
// do something...
})
1.如何播放
recordJs.stopRecord((data)=>{
// 传入结束录制回调
// data对象返回视频blob和截取的最后一帧图片url
// do something...
const { videoBlob } = data;
const videoFile = new File([videoBlob], 'filename.mp4', { type: 'video/mp4' });
const videoUrl = URL.createObjectURL(videoFile)
// 将 videoUrl 给到播放组件 video的src或者其他
// 如果使用的是video.js第三方播放器播放,
// videojs.src()调用时需指定type,
videojs.src({ src: videoUrl, type: 'video/mp4' })
})
2.播放时长显示问题,进度条不显示,或者最后几秒才出现总时长
借助方法库 “webm-duration-fix”: “^1.0.4”,实测有用
具体用法已经更新到封装的代码里
完!