优化声音和安卓端适配

This commit is contained in:
iqudoo
2026-05-25 20:48:54 +08:00
parent f52b9f3518
commit c08cdaee68
7 changed files with 256 additions and 28 deletions

View File

@@ -89,6 +89,9 @@ interface ScanConfigOptions {
webScanType?: ("qrCode" | "barCode")[];
webScanVideoMirror?: boolean;
webScanVideoMirrorVertical?: boolean;
webScanImageFallbackOnVideoError?: boolean;
webScanVideoAccessTimeout?: number;
webScanVideoReadyTimeout?: number;
scanBeepAudio?: string;
scanBeepEnabled?: boolean;
initWechatJssdk?: {
@@ -120,6 +123,9 @@ interface ScanConfigOptions {
| `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` |
| `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 |
| `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` |
| `webScanImageFallbackOnVideoError` | 摄像头不可用或打开失败时,是否自动弹出拍照/选图(适用于部分安卓内置浏览器) | `true` |
| `webScanVideoAccessTimeout` | 打开摄像头超时(毫秒),超时后走图片回退 | `10000` |
| `webScanVideoReadyTimeout` | 摄像头已开但无画面超时(毫秒),超时后走图片回退 | `8000` |
| `scanBeepAudio` | 扫码成功提示音地址(任意模式匹配成功时播放) | 内置提示音 |
| `scanBeepEnabled` | 扫码成功是否播放提示音 | `true` |
| `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 |

12
dist/index.d.ts vendored
View File

@@ -89,6 +89,18 @@ interface ScanConfigOptions {
* 网页扫码视频是否垂直镜像,默认不镜像
*/
webScanVideoMirrorVertical?: boolean,
/**
* 摄像头不可用(如部分安卓内置浏览器禁用 getUserMedia时是否自动回退为拍照/选图识别,默认启用
*/
webScanImageFallbackOnVideoError?: boolean,
/**
* 打开摄像头超时时间(毫秒),超时后触发图片回退,默认 10000
*/
webScanVideoAccessTimeout?: number,
/**
* 摄像头已打开但长时间无画面时触发图片回退(毫秒),默认 8000
*/
webScanVideoReadyTimeout?: number,
/**
* 扫码成功提示音地址,默认使用内置提示音;任意识别模式匹配成功时播放
*/

2
dist/index.js vendored

File diff suppressed because one or more lines are too long

6
dist/index.md vendored
View File

@@ -89,6 +89,9 @@ interface ScanConfigOptions {
webScanType?: ("qrCode" | "barCode")[];
webScanVideoMirror?: boolean;
webScanVideoMirrorVertical?: boolean;
webScanImageFallbackOnVideoError?: boolean;
webScanVideoAccessTimeout?: number;
webScanVideoReadyTimeout?: number;
scanBeepAudio?: string;
scanBeepEnabled?: boolean;
initWechatJssdk?: {
@@ -120,6 +123,9 @@ interface ScanConfigOptions {
| `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` |
| `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 |
| `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` |
| `webScanImageFallbackOnVideoError` | 摄像头不可用或打开失败时,是否自动弹出拍照/选图(适用于部分安卓内置浏览器) | `true` |
| `webScanVideoAccessTimeout` | 打开摄像头超时(毫秒),超时后走图片回退 | `10000` |
| `webScanVideoReadyTimeout` | 摄像头已开但无画面超时(毫秒),超时后走图片回退 | `8000` |
| `scanBeepAudio` | 扫码成功提示音地址(任意模式匹配成功时播放) | 内置提示音 |
| `scanBeepEnabled` | 扫码成功是否播放提示音 | `true` |
| `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 |

View File

@@ -1,5 +1,14 @@
import { inRuntime, bridgeAsync } from "../bridge";
import { isSupportWebScan, startScanForWeb, stopScanForWeb, isSupportImageScan, startScanForImage, unlockScanBeep, playScanBeep } from "../web";
import {
isSupportWebScan,
startScanForWeb,
stopScanForWeb,
isSupportImageScan,
startScanForImage,
unlockScanBeep,
playScanBeep,
isWebScanImageFallbackEnabled
} from "../web";
import { isSupportWxScan, startScanForWx } from "../wx";
import { startScanner, stopScanner } from "../scanner";
import { getConfig } from "../config";
@@ -273,6 +282,35 @@ function __startWxScan() {
});
}
function __startWebScan() {
return startScanForWeb(__result).then(resp => {
if (!isScanning()) {
return resp;
}
if (!resp || !resp.result) {
return resp;
}
if (__result(resp.result)) {
return resp;
}
if (isScanning()) {
return __startWebScan();
}
return resp;
}).catch(err => {
if (!isScanning()) {
return err;
}
if (err && err.cancel || err && err.imageFallbackUsed) {
return err;
}
if (isWebScanImageFallbackEnabled()) {
return __startImageScan();
}
return err;
});
}
function __startImageScan() {
return startScanForImage().then(resp => {
if (!isScanning()) {
@@ -414,7 +452,7 @@ export function startScan() {
} else if (isSupportWxScan()) {
scanPromise = __startWxScan();
} else if (isSupportWebScan()) {
scanPromise = startScanForWeb(__result);
scanPromise = __startWebScan();
} else if (isSupportImageScan()) {
scanPromise = __startImageScan();
} else {

View File

@@ -30,8 +30,12 @@ const currentScriptSrc = typeof document !== "undefined"
let barcodeDetectorPreparePromise = null;
let scanBeepAudioEl = null;
let scanBeepAudioSrc = null;
let scanBeepUnlockAudioEl = null;
let scanBeepUnlocked = false;
let scanBeepUnlocking = false;
let scanBeepGestureUnlockInstalled = false;
/** 极短静音 WAV仅用于在用户手势内解锁自动播放不播放真实提示音文件 */
const SILENT_BEEP_UNLOCK_AUDIO = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=";
function removeEl(id, uuid) {
try {
@@ -357,6 +361,24 @@ export function playScanBeep() {
}
}
function getScanBeepUnlockAudio() {
if (typeof Audio === "undefined") {
return null;
}
if (!scanBeepUnlockAudioEl) {
scanBeepUnlockAudioEl = new Audio(SILENT_BEEP_UNLOCK_AUDIO);
scanBeepUnlockAudioEl.preload = "auto";
scanBeepUnlockAudioEl.muted = true;
scanBeepUnlockAudioEl.volume = 0;
scanBeepUnlockAudioEl.setAttribute("playsinline", "");
try {
scanBeepUnlockAudioEl.load && scanBeepUnlockAudioEl.load();
} catch (e) {
}
}
return scanBeepUnlockAudioEl;
}
function getScanBeepAudio() {
const audioSrc = getScanBeepAudioSrc();
if (!audioSrc || typeof Audio === 'undefined') {
@@ -367,6 +389,7 @@ function getScanBeepAudio() {
scanBeepUnlocked = false;
scanBeepAudioEl = new Audio(audioSrc);
scanBeepAudioEl.preload = "auto";
scanBeepAudioEl.setAttribute("playsinline", "");
try {
scanBeepAudioEl.load && scanBeepAudioEl.load();
} catch (e) {
@@ -377,43 +400,35 @@ function getScanBeepAudio() {
}
export function unlockScanBeep() {
if (!isScanBeepEnabled() || scanBeepUnlocked) {
if (!isScanBeepEnabled() || scanBeepUnlocked || scanBeepUnlocking) {
return;
}
const audio = getScanBeepAudio();
getScanBeepAudio();
const audio = getScanBeepUnlockAudio();
if (!audio) {
return;
}
const prevMuted = audio.muted;
const prevVolume = audio.volume;
scanBeepUnlocking = true;
try {
audio.muted = true;
audio.volume = 0;
audio.currentTime = 0;
const playPromise = audio.play();
if (!playPromise || !playPromise.then) {
const finishUnlock = () => {
resetScanBeepPlayback(audio);
audio.muted = prevMuted;
audio.volume = prevVolume;
scanBeepUnlocking = false;
scanBeepUnlocked = true;
};
if (!playPromise || !playPromise.then) {
finishUnlock();
return;
}
playPromise.then(() => {
playPromise.then(finishUnlock).catch(() => {
resetScanBeepPlayback(audio);
audio.muted = prevMuted;
audio.volume = prevVolume;
scanBeepUnlocked = true;
}).catch(() => {
resetScanBeepPlayback(audio);
audio.muted = prevMuted;
audio.volume = prevVolume;
scanBeepUnlocking = false;
});
} catch (e) {
try {
audio.muted = prevMuted;
audio.volume = prevVolume;
} catch (_e) {
}
scanBeepUnlocking = false;
}
}
@@ -431,17 +446,120 @@ export function isSupportImageScan() {
&& !!URL.createObjectURL;
}
export function isWebScanImageFallbackEnabled() {
return isSupportImageScan() && getConfig("webScanImageFallbackOnVideoError") !== false;
}
function getWebScanVideoAccessTimeout() {
const timeout = getConfig("webScanVideoAccessTimeout");
return typeof timeout === "number" && timeout > 0 ? timeout : 10000;
}
function getWebScanVideoReadyTimeout() {
const timeout = getConfig("webScanVideoReadyTimeout");
return typeof timeout === "number" && timeout > 0 ? timeout : 8000;
}
function getUserMediaWithTimeout(constraints, timeoutMs) {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
reject(new Error("getUserMedia timeout"));
}, timeoutMs);
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
if (settled) {
stopMediaStream(stream);
return;
}
settled = true;
clearTimeout(timer);
resolve(stream);
}).catch(err => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
reject(err);
});
});
}
function runWebScanImageFallback(onResult, resolve, reject) {
stopActiveWebScan();
unlockScanBeep();
return createBarcodeDetector(getConfig("webScanType")).then(detector => {
return chooseImageFile({ preferCapture: isMobile() }).then(file => {
if (!file) {
return Promise.reject({ cancel: 1 });
}
return detectImageFile(detector, file);
}).then(code => {
if (code && code.rawValue) {
if (!onResult || !onResult(code.rawValue)) {
return Promise.reject({ cancel: 1 });
}
scanWeb.finish = true;
resolve({
result: code.rawValue
});
return;
}
return Promise.reject({
success: false,
error: "未识别到二维码或条形码",
imageFallbackUsed: true
});
});
}).catch(err => {
if (err && err.cancel) {
reject({ cancel: 1, imageFallbackUsed: true });
return;
}
if (err && err.result) {
reject(Object.assign({ imageFallbackUsed: true }, err));
return;
}
reject({
imageFallbackUsed: true,
error: err && err.error ? err.error : err
});
});
}
function tryWebScanImageFallback(onResult, resolve, reject, state) {
if (!isWebScanImageFallbackEnabled()) {
return false;
}
if (state && state.imageFallbackStarted) {
return false;
}
if (state) {
state.imageFallbackStarted = true;
}
runWebScanImageFallback(onResult, resolve, reject);
return true;
}
export function stopScanForWeb() {
return Promise.resolve().then(() => {
stopActiveWebScan();
})
}
function chooseImageFile() {
function chooseImageFile(options) {
options = options || {};
return new Promise(resolve => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
if (options.preferCapture) {
input.setAttribute("capture", options.captureMode || "environment");
}
input.style.display = "none";
let finished = false;
let finish = file => {
@@ -523,6 +641,7 @@ export function startScanForImage() {
export function startScanForWeb(onResult) {
let currentUuid = null;
const fallbackState = { imageFallbackStarted: false };
return new Promise((resolve, reject) => {
try {
stopActiveWebScan();
@@ -581,11 +700,11 @@ export function startScanForWeb(onResult) {
videoEl.uuid = scanWeb.uuid;
canvasEl.uuid = scanWeb.uuid;
createBarcodeDetector(getConfig("webScanType")).then(detector => {
return navigator.mediaDevices.getUserMedia({
return getUserMediaWithTimeout({
video: {
facingMode: "environment"
}
}).then(stream => {
}, getWebScanVideoAccessTimeout()).then(stream => {
return {
detector,
stream
@@ -605,16 +724,38 @@ export function startScanForWeb(onResult) {
const mirrorVideoVertical = shouldMirrorWebVideoVertical();
videoEl.srcObject = stream;
videoEl.setAttribute("playsinline", true); // iOS使用
videoEl.play();
canvasEl.style.display = "none";
let detecting = false;
let displayed = false;
let closed = false;
let videoReadyTimer = null;
const clearVideoReadyTimer = () => {
if (videoReadyTimer) {
clearTimeout(videoReadyTimer);
videoReadyTimer = null;
}
};
const scheduleVideoReadyTimeout = () => {
clearVideoReadyTimer();
videoReadyTimer = setTimeout(() => {
if (scanWeb.uuid !== currentUuid || closed || scanWeb.finish) {
return;
}
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
return;
}
closed = true;
close();
tryWebScanImageFallback(onResult, resolve, reject, fallbackState);
}, getWebScanVideoReadyTimeout());
};
scheduleVideoReadyTimeout();
let close = () => {
if (closed) {
return;
}
closed = true;
clearVideoReadyTimer();
stopMediaStream(stream);
if (scanWeb.uuid === currentUuid || scanWeb.stream === stream) {
scanWeb.stream = null;
@@ -631,6 +772,16 @@ export function startScanForWeb(onResult) {
pickImageButtonEl.style.display = "none";
}
};
const videoPlayPromise = videoEl.play && videoEl.play();
if (videoPlayPromise && videoPlayPromise.catch) {
videoPlayPromise.catch(() => {
if (scanWeb.uuid !== currentUuid || closed || scanWeb.finish) {
return;
}
close();
tryWebScanImageFallback(onResult, resolve, reject, fallbackState);
});
}
if (pickImageButtonEl) {
pickImageButtonEl.onclick = event => {
event.preventDefault && event.preventDefault();
@@ -664,6 +815,7 @@ export function startScanForWeb(onResult) {
let tick = () => {
try {
if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA && !detecting) {
clearVideoReadyTimer();
canvasEl.width = canvasDisplaySize.width;
canvasEl.height = canvasDisplaySize.height;
const cover = getCoverDrawOptions(videoEl.videoWidth, videoEl.videoHeight, canvasEl.width, canvasEl.height);
@@ -730,7 +882,9 @@ export function startScanForWeb(onResult) {
tick();
});
}).catch(err => {
reject({ error: err });
if (!tryWebScanImageFallback(onResult, resolve, reject, fallbackState)) {
reject({ error: err });
}
});
} catch (e) {
reject({ error: e });

12
types/index.d.ts vendored
View File

@@ -89,6 +89,18 @@ interface ScanConfigOptions {
* 网页扫码视频是否垂直镜像,默认不镜像
*/
webScanVideoMirrorVertical?: boolean,
/**
* 摄像头不可用(如部分安卓内置浏览器禁用 getUserMedia时是否自动回退为拍照/选图识别,默认启用
*/
webScanImageFallbackOnVideoError?: boolean,
/**
* 打开摄像头超时时间(毫秒),超时后触发图片回退,默认 10000
*/
webScanVideoAccessTimeout?: number,
/**
* 摄像头已打开但长时间无画面时触发图片回退(毫秒),默认 8000
*/
webScanVideoReadyTimeout?: number,
/**
* 扫码成功提示音地址,默认使用内置提示音;任意识别模式匹配成功时播放
*/