import { ref, onUnmounted } from 'vue' /** * 节拍器后台播放专版 - 基于 BackgroundAudioManager * * 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制: * - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效 * - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API * * 取舍: * - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复 * - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍) * 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环 * * 副作用: * - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停) * - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages) * - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受 */ export type LoopId = 'slow' | 'normal' | 'brisk' export interface LoopPreset { id: LoopId bpm: number accent: number src: string label: string } /** * 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源 * 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名 * * 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou): * - loop_80bpm_2.mp3 循环音轨 慢走档 * - loop_110bpm_2.mp3 循环音轨 健走档 * - loop_130bpm_2.mp3 循环音轨 快走档 * * 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积, * 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理 */ export const LOOP_PRESETS: readonly LoopPreset[] = [ { id: 'slow', bpm: 80, accent: 2, src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3', label: '慢走 80 BPM', }, { id: 'normal', bpm: 110, accent: 2, src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3', label: '健走 110 BPM', }, { id: 'brisk', bpm: 130, accent: 2, src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3', label: '快走 130 BPM', }, ] /** * 锁屏控制条封面 * 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里 * 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名 */ const COVER_URL = '' export function useMetronomeBg() { const isPlaying = ref(false) const currentLoop = ref(null) /* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */ let bgm: any = null /* 当前期望的 src,onEnded 时用它重新赋值实现循环 */ let desiredSrc = '' /* 懒初始化 BackgroundAudioManager + 绑定事件 BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */ const ensureBgm = () => { if (bgm) return bgm const m = uni.getBackgroundAudioManager() /* 必填 metadata,缺一会报错或不显示锁屏控制条 */ m.title = '节拍器' m.epname = '甄养堂 · 健走' m.singer = '健走配速' if (COVER_URL) m.coverImgUrl = COVER_URL m.webUrl = '' m.onPlay(() => { isPlaying.value = true }) m.onPause(() => { /* 用户从锁屏控制条点暂停,同步 UI 状态 */ isPlaying.value = false }) m.onStop(() => { isPlaying.value = false currentLoop.value = null }) /* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放 BgAudio 没有原生 loop 属性,只能用这招 */ m.onEnded(() => { if (desiredSrc && isPlaying.value) { try { m.src = desiredSrc } catch (_) {} } }) m.onError((err) => { console.error('[BgAudio] error:', err) isPlaying.value = false uni.showToast({ title: '音频播放失败,请重试', icon: 'none', duration: 2000, }) }) bgm = m return m } /** * 切到指定档位并开始播放 * 如果已经在播同一档位 → 切到 pause/play 状态 * 如果在播别的档位 → 切换 src(有 200~500ms 延迟) */ const playLoop = (id: LoopId) => { const preset = LOOP_PRESETS.find((p) => p.id === id) if (!preset) return const m = ensureBgm() /* 同档位再点一下 = 暂停 */ if (currentLoop.value === id && isPlaying.value) { m.pause() return } /* 切换档位或从暂停恢复 */ currentLoop.value = id desiredSrc = preset.src /* 重设 title 让锁屏控制条显示当前档位 */ m.title = `节拍器 · ${preset.label}` /* 赋值 src 会自动播放(微信 API 设计如此) */ m.src = preset.src /* isPlaying 由 onPlay 回调置 true */ } const pause = () => { if (bgm && isPlaying.value) { bgm.pause() } } const resume = () => { if (bgm && !isPlaying.value && desiredSrc) { /* 从暂停态恢复:直接 play 即可 */ try { bgm.play() } catch (_) { /* 部分基础库 play() 不可用时,重赋 src */ bgm.src = desiredSrc } } } /** * 完全停止 + 清掉锁屏控制条 * 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例 */ const stop = () => { if (bgm) { try { bgm.stop() } catch (_) {} } currentLoop.value = null desiredSrc = '' isPlaying.value = false } /* hook 卸载时不主动 stop,因为用户离开节拍器页时 仍希望音乐持续(走在路上拿出手机切别的页面应该不停) 真正停止的责任在 metronome.vue 的退出按钮里 */ onUnmounted(() => { /* 仅解绑回调? BgAudio 是全局单例,我们的回调还在, 不解会导致内存中保留无用引用,但回调里都判断了 isPlaying, 且新页面再 ensureBgm 时会覆盖回调,可接受 */ }) return { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS, } }