Files
shoot-miniprograms/src/audioManager.js
2026-01-06 18:13:55 +08:00

702 lines
23 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.

export const audioFils = {
// 激光已校准:
// "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/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/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3",
射击无效:
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya55ufiiw8oo55.mp3",
未上靶:
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6n45o3tsm1v4unam.mp3",
"1环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin1aq7gxjih5l.mp3",
"2环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin64tdgx2s4at.mp3",
"3环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinlmf87vt8z65.mp3",
"4环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinniv97sx0q9u.mp3",
"5环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin7j01kknpb7k.mp3",
"6环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin4syy1015rtq.mp3",
"7环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin3iz3dvmjdai.mp3",
"8环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinnjd42lhpfiw.mp3",
"9环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin69nj1xh7yfz.mp3",
"10环":
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinnvsx0tt7ksa.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",
};
// 版本控制日志函数
function debugLog(...args) {
// 获取当前环境信息
const accountInfo = uni.getAccountInfoSync();
const envVersion = accountInfo.miniProgram.envVersion;
// 只在体验版打印日志,正式版(release)和开发版(develop)不打印
if (envVersion === "trial") {
console.log(...args);
}
}
class AudioManager {
constructor() {
this.audioMap = new Map();
this.currentPlayingKey = null;
this.maxRetries = 3;
// 多轮统一重试:最多重试的轮次与每轮间隔
this.maxRetryRounds = 10;
this.retryRoundIntervalMs = 1500;
// 显式授权播放标记,防止 iOS 在设置 src 后误播
this.allowPlayMap = new Map();
// 串行加载相关属性
this.audioKeys = [];
this.currentLoadingIndex = 0;
this.isLoading = false;
this.loadingPromise = null;
// 连续播放队列相关属性
this.sequenceQueue = [];
this.sequenceIndex = 0;
this.isSequenceRunning = false;
// 防重复播放保护
this.lastPlayKey = null;
this.lastPlayAt = 0;
// 静音开关
this.isMuted = false;
this.pendingPlayKey = null;
// 新增:就绪状态映射
this.readyMap = new Map();
// 新增:首轮失败的音频集合与重试阶段标识
this.failedLoadKeys = new Set();
// 加载代数,用于 reloadAll 时作废旧的加载循环
this.loadGeneration = 0;
// 本地路径缓存 Map: { url: localPath }
this.localFileCache = uni.getStorageSync("audio_local_files") || {};
// 启动时自动清理过期的缓存文件URL 已不在 audioFils 中的文件)
this.cleanObsoleteCache();
this.initAudios();
}
// 清理不再使用的缓存文件
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("废弃缓存清理完成");
}
}
// 初始化音频(两阶段:首轮串行加载全部,次轮仅串行加载失败项一次)
initAudios() {
if (this.isLoading) {
debugLog("音频正在加载中,跳过重复初始化");
return this.loadingPromise;
}
debugLog("开始串行加载音频...");
this.isLoading = true;
this.audioKeys = Object.keys(audioFils);
this.currentLoadingIndex = 0;
this.failedLoadKeys.clear();
// 增加代数,使得旧的加载循环失效
this.loadGeneration = (this.loadGeneration || 0) + 1;
const currentGen = this.loadGeneration;
this.loadingPromise = new Promise((resolve) => {
const finalize = () => {
if (currentGen !== this.loadGeneration) return;
const runRounds = (round) => {
if (currentGen !== this.loadGeneration) return;
// 达到最大轮次或没有失败项,收尾
if (this.failedLoadKeys.size === 0 || round > this.maxRetryRounds) {
this.isLoading = false;
resolve();
return;
}
const retryKeys = Array.from(this.failedLoadKeys);
this.failedLoadKeys.clear();
debugLog(`开始第 ${round} 轮串行加载,共 ${retryKeys.length}`);
this.loadKeysSequentially(
retryKeys,
() => {
if (currentGen !== this.loadGeneration) return;
// 如仍有失败项,继续下一轮;否则结束
if (this.failedLoadKeys.size > 0 && round < this.maxRetryRounds) {
setTimeout(
() => runRounds(round + 1),
this.retryRoundIntervalMs
);
} else {
this.isLoading = false;
resolve();
}
},
currentGen
);
};
// 启动第 1 轮重试(如有失败项)
runRounds(1);
};
this.loadNextAudio(finalize, currentGen);
});
return this.loadingPromise;
}
// 按自定义列表串行加载音频(避免并发过多)
loadKeysSequentially(keys, onComplete, gen) {
if (gen !== undefined && gen !== this.loadGeneration) return;
let idx = 0;
const list = Array.from(keys);
const next = () => {
if (gen !== undefined && gen !== this.loadGeneration) return;
if (idx >= list.length) {
if (onComplete) onComplete();
return;
}
const k = list[idx++];
// 已就绪的音频不再重载,避免把 ready 状态重置为 false
if (this.readyMap.get(k)) {
setTimeout(next, 50);
return;
}
// 未就绪:已存在则重载;不存在则创建
if (this.audioMap.has(k)) {
this.retryLoadAudio(k);
setTimeout(next, 100);
} else {
this.createAudio(k, () => {
setTimeout(next, 100);
});
return; // createAudio 内部会触发 next
}
};
next();
}
// 串行加载下一个音频(首轮)
loadNextAudio(onComplete, gen) {
if (gen !== undefined && gen !== this.loadGeneration) return;
if (this.currentLoadingIndex >= this.audioKeys.length) {
debugLog("首轮加载遍历完成", this.currentLoadingIndex);
if (onComplete) onComplete();
return;
}
const key = this.audioKeys[this.currentLoadingIndex];
debugLog(
`开始加载音频 ${this.currentLoadingIndex + 1}/${
this.audioKeys.length
}: ${key}`
);
this.createAudio(key, () => {
setTimeout(() => {
this.loadNextAudio(onComplete, gen);
}, 100);
});
}
// 创建单个音频实例(支持本地缓存)
createAudio(key, callback) {
this.currentLoadingIndex++;
const src = audioFils[key];
const setupAudio = (realSrc) => {
const audio = uni.createInnerAudioContext();
audio.autoplay = false;
audio.src = realSrc;
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 (_) {}
}
});
const loadTimeout = setTimeout(() => {
debugLog(`音频 ${key} 加载超时`);
this.recordLoadFailure(key);
try {
audio.destroy();
} catch (_) {}
if (callback) callback();
}, 10000);
audio.onCanplay(() => {
if (!this.allowPlayMap.get(key)) {
try {
audio.pause();
} catch (_) {}
}
clearTimeout(loadTimeout);
this.readyMap.set(key, true);
this.failedLoadKeys.delete(key);
// debugLog(`音频 ${key} 已加载完成`, this.getLoadProgress());
uni.$emit("audioLoaded", key);
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
loadedAudioKeys[key] = true;
uni.setStorageSync("loadedAudioKeys", loadedAudioKeys);
if (callback) callback();
});
audio.onError((res) => {
clearTimeout(loadTimeout);
debugLog(`音频 ${key} 加载失败:`, res.errMsg);
// 如果是本地文件加载失败,可能是文件损坏,清除缓存以便下次重新下载
if (realSrc !== src && this.localFileCache[src] === realSrc) {
debugLog(`本地缓存失效,移除记录: ${key}`);
delete this.localFileCache[src];
uni.setStorageSync("audio_local_files", this.localFileCache);
// 移除文件
uni.removeSavedFile({ filePath: realSrc });
}
this.recordLoadFailure(key);
this.audioMap.delete(key);
audio.destroy();
if (this.readyMap.get(key)) {
// 这里不要去除,不然检查进度的时候由于没有重新加载而进度卡住,等播放失败的时候会重新加载
// this.readyMap.set(key, false);
} else {
if (callback) callback();
}
});
audio.onEnded(() => {
if (this.currentPlayingKey === key) {
this.currentPlayingKey = null;
}
this.allowPlayMap.set(key, false);
this.onAudioEnded(key);
});
audio.onStop(() => {
if (this.currentPlayingKey === key) {
this.currentPlayingKey = null;
}
this.allowPlayMap.set(key, false);
});
this.audioMap.set(key, audio);
};
// 检查是否有可用的本地缓存
this.checkLocalFile(src).then((localPath) => {
if (localPath) {
debugLog(`命中本地缓存: ${key}`);
setupAudio(localPath);
} else {
// 下载并尝试保存
uni.downloadFile({
url: src,
timeout: 20000,
success: (res) => {
if (res.tempFilePath) {
// 尝试保存文件到本地存储(持久化)
uni.getFileSystemManager().saveFile({
tempFilePath: res.tempFilePath,
success: (saveRes) => {
const savedPath = saveRes.savedFilePath;
this.localFileCache[src] = savedPath;
uni.setStorageSync("audio_local_files", this.localFileCache);
debugLog(`音频已缓存到本地: ${key}`);
setupAudio(savedPath);
},
fail: (err) => {
debugLog(
`保存音频失败(可能空间不足),使用临时文件: ${key}`,
err
);
setupAudio(res.tempFilePath);
},
});
} else {
this.recordLoadFailure(key);
if (callback) callback();
}
},
fail: () => {
this.recordLoadFailure(key);
if (callback) callback();
},
});
}
});
}
// 检查本地文件是否有效
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);
},
});
});
}
// 新增:记录失败(首轮与次轮都会用到)
recordLoadFailure(key) {
this.failedLoadKeys.add(key);
}
// 重新加载音频
retryLoadAudio(key) {
const oldAudio = this.audioMap.get(key);
if (oldAudio) oldAudio.destroy();
this.createAudio(key);
}
// 播放指定音频或音频数组(数组则按顺序连续播放)
play(input, interrupt = true) {
// 统一规范化为队列
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);
}
}
// 内部方法:播放单个 key
_playSingle(key, forceStopAll = false) {
// 200ms 内的同 key 重复播放直接忽略,避免“比比赛开始”这类重复首音
const now = Date.now();
if (this.lastPlayKey === key && now - this.lastPlayAt < 250) {
debugLog(`忽略快速重复播放: ${key}`);
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) {
// 播放前确保遵循当前静音状态
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);
audio.play();
this.currentPlayingKey = key;
this.lastPlayKey = key;
this.lastPlayAt = Date.now();
} else {
debugLog(`音频 ${key} 不存在,尝试重新加载...`);
this.retryLoadAudio(key);
const handler = (loadedKey) => {
if (loadedKey === key) {
try {
uni.$off("audioLoaded", handler);
} catch (_) {}
// 再次校验是否存在且就绪
const a = this.audioMap.get(key);
if (a && this.readyMap.get(key)) {
this._playSingle(key, false);
}
}
};
try {
uni.$on("audioLoaded", handler);
} catch (_) {}
}
}
// 连续播放:在某个音频结束后,若处于队列播放状态则继续下一个
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);
if (audio) {
audio.stop();
this.allowPlayMap.set(key, false);
if (this.currentPlayingKey === key) {
this.currentPlayingKey = null;
}
}
}
// 停止所有音频
stopAll() {
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: 重置所有状态并重新加载");
// 1. 停止所有播放
this.stopAll();
// 2. 销毁现有音频实例
for (const audio of this.audioMap.values()) {
try {
audio.destroy();
} catch (_) {}
}
this.audioMap.clear();
// 3. 重置状态
this.readyMap.clear();
this.failedLoadKeys.clear();
this.allowPlayMap.clear();
this.currentPlayingKey = null;
this.sequenceQueue = [];
this.sequenceIndex = 0;
this.isSequenceRunning = false;
// 清理一下可能损坏的缓存(可选,如果用户因为缓存坏了卡住,这一步很有用)
// 这里选择不自动全清,而是依赖 onError 里的单点清除。如果需要彻底重置,可取消注释:
// this.clearCache();
// 4. 强制重置加载锁
this.isLoading = false;
this.loadingPromise = null;
this.currentLoadingIndex = 0;
// 5. 重新初始化 (initAudios 会自增 loadGeneration从而终止之前的任何异步循环)
return this.initAudios();
}
}
// 导出单例
export default new AudioManager();