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", "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/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/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();