Files
zyt/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts
T
long 3325be0622 feat(metronome): 双引擎(BgAudio 档位+InnerAudio 自定义)+音频迁移 CDN
- 档位模式用 BackgroundAudioManager+预合成循环 mp3, 锁屏可继续播放
- 自定义模式保留 InnerAudio, 支持任意 BPM(40~240)/拍号(2/3/4), 仅前台
- UI 折叠区区分两种模式, preset 默认进, 点"⚙ 自定义节奏"切换
- 音频资产全部迁移到腾讯云 COS, 删除本地 6 个 mp3/jpg (-210KB)
- server 上传白名单补 mp3/wav/m4a/aac/amr/wma 格式
2026-05-26 11:15:15 +08:00

214 lines
6.7 KiB
TypeScript
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.
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<boolean>(false)
const currentLoop = ref<LoopId | null>(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,
}
}