2025-09-30 09:18:31 +08:00

513 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 摄像头扫描器模块
* 负责处理摄像头访问、视频流管理和图像数据获取
*/
class CameraScanner {
constructor() {
this.video = null;
this.canvas = null;
this.ctx = null;
this.stream = null;
this.drawFrameId = null; // 添加绘制帧ID
this.lastDrawTime = 0; // 上次绘制时间
this.drawInterval = 33; // 绘制间隔约30fps
this.config = {
width: 640,
height: 480,
facingMode: 'environment' // 后置摄像头
};
}
/**
* 检测设备类型
* @returns {string} 'mobile' | 'tablet' | 'desktop'
*/
detectDeviceType() {
const userAgent = navigator.userAgent;
const screenWidth = window.innerWidth;
// 检测移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
// 更精确的判断
if (isMobile) {
// iPad或大屏手机视为平板
if (/iPad/i.test(userAgent) || screenWidth >= 768) {
return 'tablet';
}
return 'mobile';
}
// PC端
return 'desktop';
}
/**
* 判断是否为手机设备
*/
isMobileDevice() {
return this.detectDeviceType() === 'mobile';
}
/**
* 初始化摄像头
* @param {HTMLCanvasElement} canvasElement - canvas元素
* @param {Object} config - 配置选项
* @param {Function} onVideoSizeReady - 视频尺寸确定后的回调函数
*/
async init(canvasElement, config = {}, onVideoSizeReady = null) {
try {
this.canvas = canvasElement;
this.ctx = this.canvas.getContext('2d');
this.config = { ...this.config, ...config };
this.onVideoSizeReady = onVideoSizeReady;
// 获取video元素由UIManager创建
this.video = document.querySelector('.jz-scanner-video');
if (!this.video) {
throw new Error('Video元素未找到');
}
// 获取摄像头流
await this.startCamera();
// 摄像头初始化成功
} catch (error) {
console.error('摄像头初始化失败:', error);
throw error;
}
}
/**
* 启动摄像头
*/
async startCamera() {
try {
const constraints = {
video: {
width: { ideal: this.config.width },
height: { ideal: this.config.height },
facingMode: { ideal: this.config.facingMode }
},
audio: false
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
this.video.srcObject = this.stream;
// 等待视频开始播放
await new Promise((resolve, reject) => {
this.video.onloadedmetadata = () => {
this.video.play().then(resolve).catch(reject);
};
this.video.onerror = reject;
});
// 设置canvas尺寸根据设备类型采用不同策略
const videoWidth = this.video.videoWidth;
const videoHeight = this.video.videoHeight;
if (videoWidth > 0 && videoHeight > 0) {
// 计算合适的canvas尺寸
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60; // 减去导航栏高度
const isMobile = this.isMobileDevice();
const videoAspectRatio = videoWidth / videoHeight;
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏显示,保持视频比例填充
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
// 视频更宽,以高度为准
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
} else {
// 视频更高,以宽度为准
canvasWidth = screenWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
} else {
// PC/平板端高度铺满宽度最大600px
const maxWidth = 600;
// 以高度为基准计算宽度
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
// 如果宽度超过最大限制,则以宽度为准
if (canvasWidth > maxWidth) {
canvasWidth = maxWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// Canvas尺寸已更新
// 通知主程序视频尺寸已确定可以更新UI中的canvas
if (this.onVideoSizeReady && typeof this.onVideoSizeReady === 'function') {
this.onVideoSizeReady(videoWidth, videoHeight);
}
} else {
console.warn('视频尺寸无效使用屏幕尺寸初始化canvas');
// 如果视频尺寸无效,根据设备类型使用不同策略
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60;
const isMobile = this.isMobileDevice();
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏
canvasWidth = screenWidth;
canvasHeight = screenHeight;
} else {
// PC/平板端高度铺满宽度最大600px
const maxWidth = 600;
canvasWidth = Math.min(screenWidth, maxWidth);
canvasHeight = screenHeight;
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// 即使视频尺寸无效也要显示canvas让用户能看到界面
this.showCanvasDirectly();
}
// 开始绘制视频帧到canvas用于二维码识别
this.drawVideoFrame();
// 在视频开始播放后再次检查尺寸,确保正确设置
setTimeout(() => {
if (this.video && this.canvas && this.video.videoWidth > 0 && this.video.videoHeight > 0) {
const newVideoWidth = this.video.videoWidth;
const newVideoHeight = this.video.videoHeight;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60;
const isMobile = this.isMobileDevice();
// 计算期望的canvas尺寸使用最新的设备适配逻辑
const videoAspectRatio = newVideoWidth / newVideoHeight;
let expectedWidth, expectedHeight;
if (isMobile) {
// 手机端全屏逻辑
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
expectedHeight = screenHeight;
expectedWidth = expectedHeight * videoAspectRatio;
} else {
expectedWidth = screenWidth;
expectedHeight = expectedWidth / videoAspectRatio;
}
} else {
// PC/平板端逻辑
const maxWidth = 600;
expectedHeight = screenHeight;
expectedWidth = expectedHeight * videoAspectRatio;
if (expectedWidth > maxWidth) {
expectedWidth = maxWidth;
expectedHeight = expectedWidth / videoAspectRatio;
}
}
expectedWidth = Math.round(expectedWidth);
expectedHeight = Math.round(expectedHeight);
// 如果尺寸有变化重新设置canvas
if (this.canvas.width !== expectedWidth || this.canvas.height !== expectedHeight) {
// 延迟检查发现canvas尺寸需要调整
this.updateCanvasSizeInternal(newVideoWidth, newVideoHeight);
}
}
}, 500); // 500ms后再次检查
// 摄像头启动成功
// 备用显示机制如果1秒后canvas仍然隐藏强制显示
setTimeout(() => {
if (this.canvas && this.canvas.style.opacity === '0') {
// 备用机制强制显示canvas
this.canvas.style.opacity = '1';
}
}, 1000);
} catch (error) {
console.error('启动摄像头失败:', error);
throw new Error('无法访问摄像头: ' + error.message);
}
}
/**
* 绘制视频帧到canvas用于二维码识别- 优化版本
*/
drawVideoFrame() {
// 检查是否需要停止绘制
if (!this.video || !this.canvas || !this.ctx || !this.stream) {
// 绘制条件不满足,停止绘制循环
return;
}
const now = Date.now();
// 控制绘制频率减少CPU占用
if (now - this.lastDrawTime >= this.drawInterval) {
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
try {
// 清除canvas以避免残留图像
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 将video画面绘制到canvas用于二维码识别
// 确保完整绘制,保持视频比例
this.ctx.drawImage(
this.video,
0, 0, this.video.videoWidth, this.video.videoHeight,
0, 0, this.canvas.width, this.canvas.height
);
this.lastDrawTime = now;
} catch (error) {
console.warn('绘制视频帧失败:', error);
// 绘制失败时也要停止循环
return;
}
}
}
// 只有在所有条件都满足时才继续绘制下一帧
if (this.video && this.canvas && this.ctx && this.stream) {
this.drawFrameId = requestAnimationFrame(() => {
this.drawVideoFrame();
});
} else {
// 绘制资源已释放,停止绘制循环
}
}
/**
* 获取当前图像数据
* @returns {ImageData|null} 图像数据
*/
getImageData() {
if (!this.canvas || !this.ctx) {
console.warn('getImageData: canvas或context不存在');
return null;
}
// 检查canvas尺寸
if (this.canvas.width <= 0 || this.canvas.height <= 0) {
console.warn('getImageData: canvas尺寸无效', {
width: this.canvas.width,
height: this.canvas.height
});
return null;
}
try {
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// 验证获取的ImageData
if (!imageData || !imageData.data || imageData.data.length === 0) {
console.warn('getImageData: 获取的ImageData无效');
return null;
}
// 验证数据长度是否与尺寸匹配
const expectedLength = this.canvas.width * this.canvas.height * 4;
if (imageData.data.length !== expectedLength) {
console.warn('getImageData: 数据长度不匹配', {
expected: expectedLength,
actual: imageData.data.length,
width: this.canvas.width,
height: this.canvas.height
});
return null;
}
return imageData;
} catch (error) {
console.error('getImageData: 获取图像数据失败', error);
return null;
}
}
/**
* 停止摄像头
*/
async stop() {
try {
// 停止绘制循环
if (this.drawFrameId) {
cancelAnimationFrame(this.drawFrameId);
this.drawFrameId = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.video) {
this.video.srcObject = null;
// 注意video元素由UIManager管理不在这里删除
this.video = null;
}
// 重置状态
this.lastDrawTime = 0;
// 摄像头已停止
} catch (error) {
console.error('停止摄像头失败:', error);
}
}
/**
* 检查摄像头权限
*/
async checkCameraPermission() {
try {
// 检查是否为HTTPS环境
if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
throw new Error('摄像头访问需要HTTPS环境当前为HTTP协议。请使用HTTPS访问或在localhost环境下测试。');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('当前浏览器不支持摄像头访问功能');
}
// 尝试获取权限状态
if (navigator.permissions) {
const permission = await navigator.permissions.query({ name: 'camera' });
if (permission.state === 'denied') {
throw new Error('摄像头权限被拒绝,请在浏览器设置中允许摄像头访问');
}
}
// 尝试访问摄像头验证权限
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
console.error('检查摄像头权限失败:', error);
// 根据错误类型提供更友好的提示
if (error.name === 'NotAllowedError') {
throw new Error('摄像头权限被拒绝,请允许访问摄像头权限');
} else if (error.name === 'NotFoundError') {
throw new Error('未检测到摄像头设备');
} else if (error.name === 'NotSupportedError' || error.name === 'OverconstrainedError') {
throw new Error('摄像头不支持请求的配置');
} else if (error.message.includes('HTTPS')) {
throw error; // 直接抛出HTTPS相关错误
} else {
throw new Error('无法访问摄像头: ' + error.message);
}
}
}
/**
* 内部方法更新canvas尺寸根据设备类型采用不同策略
*/
updateCanvasSizeInternal(videoWidth, videoHeight) {
if (!this.canvas || !videoWidth || !videoHeight) {
return;
}
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60; // 减去导航栏高度
const isMobile = this.isMobileDevice();
const videoAspectRatio = videoWidth / videoHeight;
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏显示,保持视频比例填充
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
// 视频更宽,以高度为准
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
} else {
// 视频更高,以宽度为准
canvasWidth = screenWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
} else {
// PC/平板端高度铺满宽度最大600px
const maxWidth = 600;
// 以高度为基准计算宽度
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
// 如果宽度超过最大限制,则以宽度为准
if (canvasWidth > maxWidth) {
canvasWidth = maxWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// Canvas尺寸已更新内部调用
// 通知主程序视频尺寸已确定
if (this.onVideoSizeReady && typeof this.onVideoSizeReady === 'function') {
this.onVideoSizeReady(videoWidth, videoHeight);
}
}
/**
* 直接显示canvas在视频尺寸无效时的备用方案
*/
showCanvasDirectly() {
if (!this.canvas) return;
// 延迟显示避免iOS Safari的闪烁问题
setTimeout(() => {
if (this.canvas) {
this.canvas.style.opacity = '1';
// Canvas已直接显示备用方案
}
}, 200); // 稍长延迟确保iOS Safari处理完毕
}
/**
* 检查是否支持摄像头
*/
static async isSupported() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
/**
* 获取可用摄像头列表
*/
static async getCameras() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return [];
}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === 'videoinput');
} catch (error) {
console.error('获取摄像头列表失败:', error);
return [];
}
}
}
export default CameraScanner;