- 档位模式用 BackgroundAudioManager+预合成循环 mp3, 锁屏可继续播放 - 自定义模式保留 InnerAudio, 支持任意 BPM(40~240)/拍号(2/3/4), 仅前台 - UI 折叠区区分两种模式, preset 默认进, 点"⚙ 自定义节奏"切换 - 音频资产全部迁移到腾讯云 COS, 删除本地 6 个 mp3/jpg (-210KB) - server 上传白名单补 mp3/wav/m4a/aac/amr/wma 格式
214 lines
6.7 KiB
TypeScript
214 lines
6.7 KiB
TypeScript
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,
|
||
}
|
||
}
|