1002 lines
30 KiB
JavaScript
1002 lines
30 KiB
JavaScript
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
|
|
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
|
|
|
|
export const audioFils = {
|
|
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
|
|
点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
|
|
设备连接已断开:
|
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E8%AE%BE%E5%A4%87%E8%BF%9E%E6%8E%A5%E5%B7%B2%E6%96%AD%E5%BC%80.MP3",
|
|
设备已连接:
|
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E8%AE%BE%E5%A4%87%E5%B7%B2%E8%BF%9E%E6%8E%A5.MP3",
|
|
"20CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/20CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3",
|
|
"40CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/40CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3",
|
|
// 激光已校准:
|
|
// "https://static.shelingxingqiu.com/attachment/2025-10-29/ddupaur1vdkyhzaqdc.mp3",
|
|
胜利: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcuo9yjp0kt5msvmvd.mp3",
|
|
失败: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcuo9yht2sdwhuqygy.mp3",
|
|
请射箭测试距离:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcuvj8avzqyw4hpq7t.mp3",
|
|
距离合格:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrda0amn5kqr4j.mp3",
|
|
距离不足:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6hr2faw28t0ianh0.mp3",
|
|
"未发现靶纸,请瞄准靶纸射箭":
|
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E6%9C%AA%E5%8F%91%E7%8E%B0%E9%9D%B6%E7%BA%B8%EF%BC%8C%E8%AF%B7%E7%9E%84%E5%87%86%E9%9D%B6%E7%BA%B8%E5%B0%84%E7%AE%AD.MP3",
|
|
轮到你了:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrn4lxcpv8aqr.mp3",
|
|
第一轮:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutyyr9a7m1vz2w13.mp3",
|
|
第二轮:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutyyr9ldnfexjxtw.mp3",
|
|
第三轮:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutyyr97m4ipxaze4.mp3",
|
|
第四轮:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutyyr9x5addohlzf.mp3",
|
|
第五轮:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutyyr9d7lw2gebpv.mp3",
|
|
决金箭轮:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrd9zs4oi2kujv.mp3",
|
|
请蓝方射箭:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrxcbe5ll46as.mp3",
|
|
请红方射箭:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl3re3dhlfjd.mp3",
|
|
中场休息:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrd9zdk1xyolst.mp3",
|
|
比赛结束:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya59b6pu0ur4um.mp3",
|
|
比赛开始:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcuu5z3a3lumkutske.mp3",
|
|
下半场开始:
|
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E4%B8%8B%E5%8D%8A%E5%9C%BA%E5%BC%80%E5%A7%8B.mp3",
|
|
请开始射击:
|
|
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3",
|
|
射击无效:
|
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%E6%A3%80%E6%9F%A5%E8%B7%9D%E7%A6%BB%E5%92%8C%E9%9D%B6%E7%BA%B8.mp3",
|
|
未上靶:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6n45o3tsm1v4unam.mp3",
|
|
"1环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/1.mp3",
|
|
"2环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/2.mp3",
|
|
"3环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/3.mp3",
|
|
"4环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/4.mp3",
|
|
"5环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/5.mp3",
|
|
"6环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/6.mp3",
|
|
"7环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/7.mp3",
|
|
"8环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/8.mp3",
|
|
"9环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/9.mp3",
|
|
"10环":
|
|
"https://static.shelingxingqiu.com/shootaudio/v3/10.mp3",
|
|
X环: "https://static.shelingxingqiu.com/shootaudio/v4/v4/X%E7%8E%AF.mp3",
|
|
向上调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf5pfvu3l8dhr.mp3",
|
|
向右上调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf45v88pirarr.mp3",
|
|
向右调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6elleqnhrenggxsb.mp3",
|
|
向右下调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6elleo6q16qctf6a.mp3",
|
|
向下调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellek2mu2cri2n9.mp3",
|
|
向左下调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf25yu1pt2k5r.mp3",
|
|
向左调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellen3zoalxcb06.mp3",
|
|
向左上调整:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf37a2iw6w4pu.mp3",
|
|
最后30秒:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-13/de7kzzllq0futwynso.mp3",
|
|
练习开始:
|
|
"https://static.shelingxingqiu.com/attachment/2025-11-14/de88w0lmmt43nnfmoi.mp3",
|
|
练习结束:
|
|
"https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3",
|
|
射箭声音:
|
|
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
|
|
命中:
|
|
"https://static.shelingxingqiu.com/shootaudio/%E5%91%BD%E4%B8%AD.mp3"
|
|
};
|
|
|
|
const AUDIO_LOAD_TIMEOUT_MS = 5000;
|
|
const AUDIO_WARM_CONCURRENCY = 3;
|
|
const AUDIO_WARM_RETRIES = 1;
|
|
const AUDIO_WARM_PRIORITY_KEYS = [
|
|
"点击按钮",
|
|
"设备连接已断开",
|
|
"设备已连接",
|
|
"比赛开始",
|
|
"练习开始",
|
|
"练习结束",
|
|
"请开始射击",
|
|
"轮到你了",
|
|
"比赛结束",
|
|
"射击无效",
|
|
"中场休息",
|
|
"下半场开始",
|
|
"决金箭轮",
|
|
"请蓝方射箭",
|
|
"请红方射箭",
|
|
"距离合格",
|
|
"距离不足",
|
|
"未发现靶纸,请瞄准靶纸射箭",
|
|
"未上靶",
|
|
"X环",
|
|
];
|
|
|
|
// 版本控制日志函数
|
|
function debugLog(...args) {
|
|
// 获取当前环境信息
|
|
const accountInfo = uni.getAccountInfoSync();
|
|
const envVersion = accountInfo.miniProgram.envVersion;
|
|
|
|
// 只在体验版打印日志,正式版(release)和开发版(develop)不打印
|
|
if (envVersion === "trial" || envVersion === "develop") {
|
|
console.log(...args);
|
|
}
|
|
}
|
|
|
|
class AudioManager {
|
|
constructor() {
|
|
this.audioMap = new Map();
|
|
this.currentPlayingKey = null;
|
|
// 显式授权播放标记,防止 iOS 在设置 src 后误播
|
|
this.allowPlayMap = new Map();
|
|
|
|
// 后台预热与单条加载状态
|
|
this.isLoading = false;
|
|
this.loadingKeyPromises = new Map();
|
|
this.pendingPlayKeys = new Set();
|
|
this.cacheWritePromises = new Map();
|
|
this.warmAllPromise = null;
|
|
|
|
// 连续播放队列相关属性
|
|
this.sequenceQueue = [];
|
|
this.sequenceIndex = 0;
|
|
this.isSequenceRunning = false;
|
|
|
|
// 防重复播放保护
|
|
this.lastPlayKey = null;
|
|
this.lastPlayAt = 0;
|
|
this.isInterrupted = false;
|
|
this.interruptedAt = 0;
|
|
this.interruptionFallbackMs = 5000;
|
|
this.playWatchdogMs = 8000;
|
|
this.playWatchdogTimers = new Map();
|
|
|
|
// 静音开关
|
|
this.isMuted = false;
|
|
this.pendingPlayKey = null;
|
|
this.readyMap = new Map();
|
|
// 加载代数,用于 reloadAll 时作废旧的异步加载
|
|
this.loadGeneration = 0;
|
|
// 本地路径缓存 Map: { url: localPath }
|
|
this.localFileCache = uni.getStorageSync("audio_local_files") || {};
|
|
// 启动时自动清理过期的缓存文件(URL 已不在 audioFils 中的文件)
|
|
this.cleanObsoleteCache();
|
|
this.bindAudioInterruptionEvents();
|
|
}
|
|
|
|
bindAudioInterruptionEvents() {
|
|
if (this._audioInterruptionBound) return;
|
|
this._audioInterruptionBound = true;
|
|
|
|
const begin = () => {
|
|
if (this.isInterrupted) return;
|
|
this.isInterrupted = true;
|
|
this.interruptedAt = Date.now();
|
|
this.stopAll();
|
|
this.isSequenceRunning = false;
|
|
this.sequenceQueue = [];
|
|
this.sequenceIndex = 0;
|
|
this.pendingPlayKey = null;
|
|
uni.$emit(AUDIO_INTERRUPTION_BEGIN_EVENT);
|
|
};
|
|
|
|
const end = () => {
|
|
if (!this.isInterrupted) return;
|
|
this.isInterrupted = false;
|
|
this.interruptedAt = 0;
|
|
uni.$emit(AUDIO_INTERRUPTION_END_EVENT);
|
|
void this.reloadAll();
|
|
};
|
|
|
|
if (typeof uni?.onAudioInterruptionBegin === "function") {
|
|
uni.onAudioInterruptionBegin(begin);
|
|
}
|
|
if (typeof uni?.onAudioInterruptionEnd === "function") {
|
|
uni.onAudioInterruptionEnd(end);
|
|
}
|
|
}
|
|
|
|
// 清理不再使用的缓存文件
|
|
cleanObsoleteCache() {
|
|
const activeUrls = new Set(Object.values(audioFils));
|
|
const cachedUrls = Object.keys(this.localFileCache);
|
|
let hasChanges = false;
|
|
|
|
for (const url of cachedUrls) {
|
|
if (!activeUrls.has(url)) {
|
|
debugLog(`发现废弃音频缓存,正在清理: ${url}`);
|
|
const path = this.localFileCache[url];
|
|
// 移除物理文件
|
|
uni.removeSavedFile({
|
|
filePath: path,
|
|
complete: () => {
|
|
// 忽略移除结果,直接移除记录
|
|
},
|
|
});
|
|
// 移除记录
|
|
delete this.localFileCache[url];
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
uni.setStorageSync("audio_local_files", this.localFileCache);
|
|
debugLog("废弃缓存清理完成");
|
|
}
|
|
}
|
|
|
|
getWarmupKeys() {
|
|
return Array.from(
|
|
new Set([...AUDIO_WARM_PRIORITY_KEYS, ...Object.keys(audioFils)])
|
|
);
|
|
}
|
|
|
|
ensureAudio(key) {
|
|
if (!audioFils[key]) return Promise.resolve(false);
|
|
if (this.readyMap.get(key) && this.audioMap.has(key)) {
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
const loadingPromise = this.loadingKeyPromises.get(key);
|
|
if (loadingPromise) return loadingPromise;
|
|
|
|
let promise;
|
|
promise = new Promise((resolve) => {
|
|
try {
|
|
this.createAudio(key, () => {
|
|
resolve(this.readyMap.get(key) === true && this.audioMap.has(key));
|
|
});
|
|
} catch (error) {
|
|
debugLog(`音频 ${key} 创建失败`, error);
|
|
resolve(false);
|
|
}
|
|
}).finally(() => {
|
|
if (this.loadingKeyPromises.get(key) === promise) {
|
|
this.loadingKeyPromises.delete(key);
|
|
}
|
|
});
|
|
this.loadingKeyPromises.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
async warmKeys(
|
|
keys,
|
|
{ concurrency = AUDIO_WARM_CONCURRENCY, retries = AUDIO_WARM_RETRIES } = {}
|
|
) {
|
|
const queue = Array.from(new Set(keys)).filter((key) => !!audioFils[key]);
|
|
if (!queue.length) return;
|
|
|
|
let index = 0;
|
|
const worker = async () => {
|
|
while (index < queue.length) {
|
|
const key = queue[index++];
|
|
let ready = await this.ensureAudio(key);
|
|
for (let attempt = 0; !ready && attempt < retries; attempt += 1) {
|
|
ready = await this.retryLoadAudio(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
const workerCount = Math.min(Math.max(1, concurrency), queue.length);
|
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
}
|
|
|
|
warmButton() {
|
|
return this.warmKeys(["点击按钮"], { concurrency: 1, retries: 1 });
|
|
}
|
|
|
|
warmAll() {
|
|
if (this.warmAllPromise) return this.warmAllPromise;
|
|
|
|
debugLog("开始后台分级预热全部语音");
|
|
this.isLoading = true;
|
|
this.warmAllPromise = this.warmKeys(this.getWarmupKeys())
|
|
.catch((error) => {
|
|
debugLog("后台语音预热异常", error);
|
|
})
|
|
.finally(() => {
|
|
this.isLoading = false;
|
|
debugLog("后台语音预热结束", this.getLoadProgress());
|
|
});
|
|
return this.warmAllPromise;
|
|
}
|
|
|
|
// 保留旧入口兼容测试页或其他历史调用,实际行为改为非阻塞后台预热。
|
|
initAudios() {
|
|
return this.warmAll();
|
|
}
|
|
|
|
// 创建单个音频实例:缓存命中优先使用,未命中直接使用 CDN。
|
|
createAudio(key, callback) {
|
|
const src = audioFils[key];
|
|
const generation = this.loadGeneration;
|
|
let completed = false;
|
|
|
|
const complete = () => {
|
|
if (completed) return;
|
|
completed = true;
|
|
if (callback) callback();
|
|
};
|
|
|
|
if (!src) {
|
|
complete();
|
|
return;
|
|
}
|
|
|
|
const existingAudio = this.audioMap.get(key);
|
|
if (existingAudio && !this.readyMap.get(key)) {
|
|
try {
|
|
existingAudio.destroy();
|
|
} catch (_) {}
|
|
this.audioMap.delete(key);
|
|
}
|
|
|
|
const isCurrentGeneration = () => generation === this.loadGeneration;
|
|
|
|
const setupAudio = (realSrc, resumeAfterReady = false) => {
|
|
if (!isCurrentGeneration()) {
|
|
complete();
|
|
return;
|
|
}
|
|
|
|
const audio = uni.createInnerAudioContext();
|
|
audio.autoplay = false;
|
|
try {
|
|
if (typeof audio.volume === "number") {
|
|
audio.volume = this.isMuted ? 0 : 1;
|
|
} else if (typeof audio.muted !== "undefined") {
|
|
audio.muted = this.isMuted;
|
|
}
|
|
} catch (_) {}
|
|
this.allowPlayMap.set(key, false);
|
|
audio.onPlay(() => {
|
|
if (!this.allowPlayMap.get(key)) {
|
|
try {
|
|
audio.stop();
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
|
|
let loadTimeout = null;
|
|
let readyHandled = false;
|
|
const clearLoadTimeout = () => {
|
|
if (!loadTimeout) return;
|
|
clearTimeout(loadTimeout);
|
|
loadTimeout = null;
|
|
};
|
|
|
|
const handleLoadFailure = (message) => {
|
|
if (this.audioMap.get(key) !== audio) return;
|
|
clearLoadTimeout();
|
|
debugLog(`音频 ${key} ${message}`);
|
|
|
|
const isLocalCache =
|
|
realSrc !== src && this.localFileCache[src] === realSrc;
|
|
const shouldResume =
|
|
this.allowPlayMap.get(key) === true || this.currentPlayingKey === key;
|
|
const shouldResumeDirectly = shouldResume && completed;
|
|
|
|
this.clearPlayWatchdog(key);
|
|
this.allowPlayMap.set(key, false);
|
|
if (this.currentPlayingKey === key) {
|
|
this.currentPlayingKey = null;
|
|
}
|
|
if (this.lastPlayKey === key) {
|
|
this.lastPlayKey = null;
|
|
this.lastPlayAt = 0;
|
|
}
|
|
this.readyMap.set(key, false);
|
|
this.audioMap.delete(key);
|
|
try {
|
|
audio.destroy();
|
|
} catch (_) {}
|
|
|
|
if (isLocalCache && isCurrentGeneration()) {
|
|
debugLog(`本地缓存失效,立即切换 CDN: ${key}`);
|
|
delete this.localFileCache[src];
|
|
uni.setStorageSync("audio_local_files", this.localFileCache);
|
|
uni.removeSavedFile({ filePath: realSrc });
|
|
setupAudio(src, shouldResumeDirectly);
|
|
void this.cacheRemoteFile(src, key);
|
|
return;
|
|
}
|
|
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
});
|
|
complete();
|
|
};
|
|
|
|
loadTimeout = setTimeout(() => {
|
|
handleLoadFailure("加载超时");
|
|
}, AUDIO_LOAD_TIMEOUT_MS);
|
|
|
|
audio.onCanplay(() => {
|
|
if (this.audioMap.get(key) !== audio) return;
|
|
if (readyHandled) return;
|
|
readyHandled = true;
|
|
if (!isCurrentGeneration()) {
|
|
clearLoadTimeout();
|
|
this.audioMap.delete(key);
|
|
try {
|
|
audio.destroy();
|
|
} catch (_) {}
|
|
complete();
|
|
return;
|
|
}
|
|
if (!this.allowPlayMap.get(key)) {
|
|
try {
|
|
audio.pause();
|
|
} catch (_) {}
|
|
}
|
|
clearLoadTimeout();
|
|
this.readyMap.set(key, true);
|
|
uni.$emit("audioLoaded", key);
|
|
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
|
|
loadedAudioKeys[key] = true;
|
|
uni.setStorageSync("loadedAudioKeys", loadedAudioKeys);
|
|
complete();
|
|
|
|
if (resumeAfterReady) {
|
|
setTimeout(() => {
|
|
const isSequenceCurrent =
|
|
this.isSequenceRunning &&
|
|
this.sequenceQueue[this.sequenceIndex] === key;
|
|
if (isSequenceCurrent) this._playSingle(key, false);
|
|
}, 0);
|
|
}
|
|
});
|
|
|
|
audio.onError((res) => {
|
|
handleLoadFailure(`加载失败:${res.errMsg || "unknown"}`);
|
|
});
|
|
|
|
audio.onEnded(() => {
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
});
|
|
});
|
|
|
|
audio.onStop(() => {
|
|
this.finishPlayback(key);
|
|
});
|
|
|
|
this.audioMap.set(key, audio);
|
|
audio.src = realSrc;
|
|
};
|
|
|
|
// 检查是否有可用的本地缓存
|
|
this.checkLocalFile(src).then((localPath) => {
|
|
if (!isCurrentGeneration()) {
|
|
complete();
|
|
return;
|
|
}
|
|
if (localPath) {
|
|
debugLog(`命中本地缓存: ${key}`);
|
|
setupAudio(localPath);
|
|
} else {
|
|
setupAudio(src);
|
|
void this.cacheRemoteFile(src, key);
|
|
}
|
|
}).catch(() => {
|
|
setupAudio(src);
|
|
void this.cacheRemoteFile(src, key);
|
|
});
|
|
}
|
|
|
|
cacheRemoteFile(url, key) {
|
|
if (this.localFileCache[url]) return Promise.resolve(true);
|
|
const currentPromise = this.cacheWritePromises.get(url);
|
|
if (currentPromise) return currentPromise;
|
|
|
|
let promise;
|
|
promise = new Promise((resolve) => {
|
|
if (typeof uni.downloadFile !== "function") {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
uni.downloadFile({
|
|
url,
|
|
timeout: 20000,
|
|
success: (res) => {
|
|
if (!res.tempFilePath) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
let fileSystemManager;
|
|
try {
|
|
fileSystemManager = uni.getFileSystemManager();
|
|
} catch (_) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
fileSystemManager.saveFile({
|
|
tempFilePath: res.tempFilePath,
|
|
success: (saveRes) => {
|
|
this.localFileCache[url] = saveRes.savedFilePath;
|
|
uni.setStorageSync("audio_local_files", this.localFileCache);
|
|
debugLog(`音频已后台缓存到本地: ${key}`);
|
|
resolve(true);
|
|
},
|
|
fail: () => resolve(false),
|
|
});
|
|
},
|
|
fail: () => resolve(false),
|
|
});
|
|
}).finally(() => {
|
|
if (this.cacheWritePromises.get(url) === promise) {
|
|
this.cacheWritePromises.delete(url);
|
|
}
|
|
});
|
|
|
|
this.cacheWritePromises.set(url, promise);
|
|
return promise;
|
|
}
|
|
|
|
// 检查本地文件是否有效
|
|
checkLocalFile(url) {
|
|
return new Promise((resolve) => {
|
|
const path = this.localFileCache[url];
|
|
if (!path) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
// 检查文件是否存在
|
|
uni.getFileSystemManager().getFileInfo({
|
|
filePath: path,
|
|
success: () => {
|
|
resolve(path);
|
|
},
|
|
fail: () => {
|
|
// 文件不存在,清理记录
|
|
delete this.localFileCache[url];
|
|
uni.setStorageSync("audio_local_files", this.localFileCache);
|
|
resolve(null);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
clearPlayWatchdog(key) {
|
|
const timer = this.playWatchdogTimers.get(key);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
this.playWatchdogTimers.delete(key);
|
|
}
|
|
}
|
|
|
|
clearAllPlayWatchdogs() {
|
|
for (const timer of this.playWatchdogTimers.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
this.playWatchdogTimers.clear();
|
|
}
|
|
|
|
startPlayWatchdog(key) {
|
|
this.clearPlayWatchdog(key);
|
|
const timer = setTimeout(() => {
|
|
if (this.currentPlayingKey !== key) return;
|
|
debugLog(`音频 ${key} 播放超时,跳过当前音频并继续队列`);
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
force: true,
|
|
});
|
|
this.reloadAudioKey(key);
|
|
}, this.playWatchdogMs);
|
|
this.playWatchdogTimers.set(key, timer);
|
|
}
|
|
|
|
finishPlayback(key, { advanceSequence = false, emitEnded = false, force = false } = {}) {
|
|
const wasCurrent = this.currentPlayingKey === key;
|
|
const isSequenceCurrent =
|
|
this.isSequenceRunning && this.sequenceQueue[this.sequenceIndex] === key;
|
|
|
|
this.clearPlayWatchdog(key);
|
|
this.allowPlayMap.set(key, false);
|
|
|
|
if (!force && !wasCurrent && !isSequenceCurrent) return false;
|
|
|
|
if (wasCurrent) {
|
|
this.currentPlayingKey = null;
|
|
}
|
|
|
|
if (advanceSequence && isSequenceCurrent) {
|
|
this.onAudioEnded(key);
|
|
}
|
|
|
|
if (emitEnded) {
|
|
uni.$emit("audioEnded", key);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
recoverFromInterruptionIfStale(force = false) {
|
|
if (!this.isInterrupted) return false;
|
|
const interruptedFor = Date.now() - (this.interruptedAt || Date.now());
|
|
if (!force && interruptedFor < this.interruptionFallbackMs) return false;
|
|
|
|
debugLog("音频中断状态超时,执行兜底恢复");
|
|
this.isInterrupted = false;
|
|
this.interruptedAt = 0;
|
|
uni.$emit(AUDIO_INTERRUPTION_END_EVENT);
|
|
void this.reloadAll();
|
|
return true;
|
|
}
|
|
|
|
recoverIfStale(expectedKey) {
|
|
if (this.recoverFromInterruptionIfStale(true)) return;
|
|
|
|
const key =
|
|
expectedKey || this.currentPlayingKey || this.sequenceQueue[this.sequenceIndex];
|
|
if (!key) {
|
|
if (this.isSequenceRunning) {
|
|
this.sequenceQueue = [];
|
|
this.sequenceIndex = 0;
|
|
this.isSequenceRunning = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const isStaleCurrent =
|
|
this.currentPlayingKey === key ||
|
|
(this.isSequenceRunning && this.sequenceQueue[this.sequenceIndex] === key);
|
|
if (!isStaleCurrent) return;
|
|
|
|
debugLog(`音频 ${key} 等待超时,执行轻量恢复`);
|
|
const audio = this.audioMap.get(key);
|
|
if (audio) {
|
|
try {
|
|
audio.stop();
|
|
} catch (_) {}
|
|
}
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
force: true,
|
|
});
|
|
this.reloadAudioKey(key);
|
|
}
|
|
|
|
reloadAudioKey(key) {
|
|
return this.retryLoadAudio(key);
|
|
}
|
|
|
|
// 重新加载音频
|
|
retryLoadAudio(key) {
|
|
const loadingPromise = this.loadingKeyPromises.get(key);
|
|
if (loadingPromise) return loadingPromise;
|
|
|
|
this.clearPlayWatchdog(key);
|
|
const oldAudio = this.audioMap.get(key);
|
|
if (oldAudio) {
|
|
try {
|
|
oldAudio.destroy();
|
|
} catch (_) {}
|
|
}
|
|
this.audioMap.delete(key);
|
|
this.readyMap.set(key, false);
|
|
return this.ensureAudio(key);
|
|
}
|
|
|
|
// 播放指定音频或音频数组(数组则按顺序连续播放)
|
|
play(input, interrupt = true) {
|
|
if (this.isInterrupted) {
|
|
this.recoverFromInterruptionIfStale();
|
|
}
|
|
if (this.isInterrupted) {
|
|
debugLog("音频处理中断状态,忽略播放请求");
|
|
return;
|
|
}
|
|
// 统一规范化为队列
|
|
let queue = [];
|
|
if (Array.isArray(input)) {
|
|
queue = input.filter((k) => !!audioFils[k]);
|
|
} else if (typeof input === "string") {
|
|
queue = !!audioFils[input] ? [input] : [];
|
|
} else {
|
|
debugLog("play 参数类型无效,仅支持字符串或字符串数组");
|
|
return;
|
|
}
|
|
|
|
if (queue.length === 0) {
|
|
debugLog("连续播放队列为空或无效");
|
|
return;
|
|
}
|
|
|
|
if (interrupt) {
|
|
// 立即打断并启动新的播放序列
|
|
this.stopAll();
|
|
this.isSequenceRunning = false;
|
|
this.sequenceQueue = [];
|
|
this.sequenceIndex = 0;
|
|
|
|
this.sequenceQueue = queue;
|
|
this.sequenceIndex = 0;
|
|
this.isSequenceRunning = true;
|
|
this._playSingle(queue[0], false);
|
|
return;
|
|
}
|
|
|
|
// 不打断当前播放:把新的队列加入到序列中,等待当前播放结束后衔接
|
|
if (this.currentPlayingKey) {
|
|
if (this.isSequenceRunning) {
|
|
// 已有序列在跑:直接追加
|
|
this.sequenceQueue = this.sequenceQueue.concat(queue);
|
|
} else {
|
|
// 没有序列但当前有正在播放的:以当前为序列的起点
|
|
this.isSequenceRunning = true;
|
|
this.sequenceQueue = [this.currentPlayingKey].concat(queue);
|
|
this.sequenceIndex = 0;
|
|
// 不触发 _playSingle,等待当前音频自然结束后由 onAudioEnded 接管
|
|
}
|
|
} else {
|
|
// 当前没有播放:直接启动新的序列
|
|
this.sequenceQueue = queue;
|
|
this.sequenceIndex = 0;
|
|
this.isSequenceRunning = true;
|
|
this._playSingle(queue[0], false);
|
|
}
|
|
}
|
|
|
|
waitForAudioAndPlay(key) {
|
|
if (this.pendingPlayKeys.has(key)) return;
|
|
this.pendingPlayKeys.add(key);
|
|
|
|
this.ensureAudio(key)
|
|
.then((ready) => {
|
|
const isSequenceCurrent =
|
|
this.isSequenceRunning && this.sequenceQueue[this.sequenceIndex] === key;
|
|
if (!isSequenceCurrent) return;
|
|
|
|
if (ready) {
|
|
this._playSingle(key, false);
|
|
return;
|
|
}
|
|
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
force: true,
|
|
});
|
|
})
|
|
.finally(() => {
|
|
this.pendingPlayKeys.delete(key);
|
|
});
|
|
}
|
|
|
|
// 内部方法:播放单个 key
|
|
_playSingle(key, forceStopAll = false) {
|
|
if (this.isInterrupted) {
|
|
this.recoverFromInterruptionIfStale();
|
|
}
|
|
if (this.isInterrupted) {
|
|
debugLog(`音频处理中断状态,跳过播放: ${key}`);
|
|
return;
|
|
}
|
|
// 200ms 内的同 key 重复播放直接忽略,避免“比比赛开始”这类重复首音
|
|
const now = Date.now();
|
|
if (this.lastPlayKey === key && now - this.lastPlayAt < 250) {
|
|
debugLog(`忽略快速重复播放: ${key}`);
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
force: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (forceStopAll) {
|
|
this.stopAll();
|
|
} else if (this.currentPlayingKey && this.currentPlayingKey !== key) {
|
|
this.stop(this.currentPlayingKey);
|
|
} else if (this.currentPlayingKey === key) {
|
|
// 同一音频正在播放:不重启,避免听到重复开头
|
|
return;
|
|
}
|
|
|
|
const audio = this.audioMap.get(key);
|
|
if (audio && this.readyMap.get(key)) {
|
|
// 播放前确保遵循当前静音状态
|
|
try {
|
|
if (typeof audio.volume === "number") {
|
|
audio.volume = this.isMuted ? 0 : 1;
|
|
} else if (typeof audio.muted !== "undefined") {
|
|
audio.muted = this.isMuted;
|
|
}
|
|
} catch (_) {}
|
|
// 同一音频:避免 stop() 触发 onStop 清除授权,使用 pause()+seek(0)
|
|
try {
|
|
audio.pause();
|
|
} catch (_) {}
|
|
try {
|
|
if (typeof audio.seek === "function") {
|
|
audio.seek(0);
|
|
} else {
|
|
audio.startTime = 0;
|
|
}
|
|
} catch (_) {
|
|
audio.startTime = 0;
|
|
}
|
|
|
|
// 显式授权播放并立即播放
|
|
this.allowPlayMap.set(key, true);
|
|
|
|
try {
|
|
audio.play();
|
|
} catch (err) {
|
|
this.finishPlayback(key, {
|
|
advanceSequence: true,
|
|
emitEnded: true,
|
|
force: true,
|
|
});
|
|
debugLog(`音频 ${key} 播放调用失败`, err?.errMsg || err);
|
|
return;
|
|
}
|
|
this.currentPlayingKey = key;
|
|
this.lastPlayKey = key;
|
|
this.lastPlayAt = Date.now();
|
|
this.startPlayWatchdog(key);
|
|
} else {
|
|
debugLog(`音频 ${key} 尚未就绪,按需加载后播放...`);
|
|
this.waitForAudioAndPlay(key);
|
|
}
|
|
}
|
|
|
|
// 连续播放:在某个音频结束后,若处于队列播放状态则继续下一个
|
|
onAudioEnded(key) {
|
|
if (!this.isSequenceRunning) return;
|
|
const currentKey = this.sequenceQueue[this.sequenceIndex];
|
|
if (currentKey !== key) return;
|
|
|
|
const nextIndex = this.sequenceIndex + 1;
|
|
if (nextIndex < this.sequenceQueue.length) {
|
|
this.sequenceIndex = nextIndex;
|
|
const nextKey = this.sequenceQueue[nextIndex];
|
|
this._playSingle(nextKey, false);
|
|
} else {
|
|
// 队列播放完成
|
|
this.isSequenceRunning = false;
|
|
this.sequenceQueue = [];
|
|
this.sequenceIndex = 0;
|
|
}
|
|
}
|
|
|
|
// 停止指定音频
|
|
stop(key) {
|
|
const audio = this.audioMap.get(key);
|
|
this.clearPlayWatchdog(key);
|
|
if (audio) {
|
|
audio.stop();
|
|
this.allowPlayMap.set(key, false);
|
|
if (this.currentPlayingKey === key) {
|
|
this.currentPlayingKey = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 停止所有音频
|
|
stopAll() {
|
|
this.clearAllPlayWatchdogs();
|
|
for (const [k, audio] of this.audioMap.entries()) {
|
|
try {
|
|
audio.stop();
|
|
} catch (_) {}
|
|
this.allowPlayMap.set(k, false);
|
|
}
|
|
this.currentPlayingKey = null;
|
|
}
|
|
|
|
// 设置静音开关:true 静音,false 取消静音
|
|
setMuted(muted) {
|
|
this.isMuted = !!muted;
|
|
for (const audio of this.audioMap.values()) {
|
|
try {
|
|
if (typeof audio.volume === "number") {
|
|
audio.volume = this.isMuted ? 0 : 1;
|
|
} else if (typeof audio.muted !== "undefined") {
|
|
audio.muted = this.isMuted;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
debugLog(`静音状态已设置为: ${this.isMuted}`);
|
|
}
|
|
|
|
// 新增:返回音频加载进度(0~1)
|
|
getLoadProgress() {
|
|
const keys = Object.keys(audioFils);
|
|
const total = keys.length;
|
|
if (total === 0) return 0;
|
|
let loaded = 0;
|
|
for (const k of keys) {
|
|
if (this.readyMap.get(k)) loaded++;
|
|
}
|
|
return Number((loaded / total).toFixed(2));
|
|
}
|
|
|
|
// 清理本地音频缓存文件
|
|
clearCache() {
|
|
debugLog("开始清理本地音频缓存...");
|
|
const cache = uni.getStorageSync("audio_local_files") || {};
|
|
const paths = Object.values(cache);
|
|
for (const path of paths) {
|
|
uni.removeSavedFile({
|
|
filePath: path,
|
|
complete: (res) => {
|
|
// 无论成功失败都继续
|
|
},
|
|
});
|
|
}
|
|
uni.removeStorageSync("audio_local_files");
|
|
this.localFileCache = {};
|
|
debugLog("本地音频缓存清理完成");
|
|
}
|
|
|
|
// 手动重置音频实例,并按之前的预热范围在后台恢复。
|
|
reloadAll() {
|
|
debugLog("执行 reloadAll: 重置音频实例并后台恢复");
|
|
const shouldWarmAll = this.warmAllPromise !== null;
|
|
this.loadGeneration += 1;
|
|
|
|
// 1. 停止所有播放
|
|
this.stopAll();
|
|
|
|
// 2. 销毁现有音频实例
|
|
for (const audio of this.audioMap.values()) {
|
|
try {
|
|
audio.destroy();
|
|
} catch (_) {}
|
|
}
|
|
this.audioMap.clear();
|
|
|
|
// 3. 重置状态
|
|
this.readyMap.clear();
|
|
this.allowPlayMap.clear();
|
|
this.loadingKeyPromises.clear();
|
|
this.pendingPlayKeys.clear();
|
|
this.clearAllPlayWatchdogs();
|
|
this.currentPlayingKey = null;
|
|
this.sequenceQueue = [];
|
|
this.sequenceIndex = 0;
|
|
this.isSequenceRunning = false;
|
|
|
|
// 清理一下可能损坏的缓存(可选,如果用户因为缓存坏了卡住,这一步很有用)
|
|
// 这里选择不自动全清,而是依赖 onError 里的单点清除。如果需要彻底重置,可取消注释:
|
|
// this.clearCache();
|
|
|
|
// 4. 重置后台预热状态
|
|
this.isLoading = false;
|
|
this.warmAllPromise = null;
|
|
|
|
// 5. 之前已启动过全量预热则继续全量恢复,否则只恢复按钮音效。
|
|
return shouldWarmAll ? this.warmAll() : this.warmButton();
|
|
}
|
|
}
|
|
|
|
// 导出单例
|
|
export default new AudioManager();
|