iOS 微信小程序硬限制: InnerAudioContext 切到后台/锁屏一定被挂起, 即使配置 requiredBackgroundModes 也无效。 唯一支持 iOS 真后台的音频 API 是 BackgroundAudioManager。 新增预合成档位音轨(ffmpeg 拼接 click + click-wood): - loop_80bpm_2.mp3 慢走 6.0s 4 周期循环 (72 KB) - loop_110bpm_2.mp3 健走 5.45s 5 周期循环 (66 KB) - loop_130bpm_2.mp3 快走 5.54s 6 周期循环 (67 KB) - cover.jpg 200×200 emerald 占位封面 (474B) 新 hook training/hooks/useMetronomeBg.ts: - 封装 uni.getBackgroundAudioManager() 单例 - 必填 metadata: title/coverImgUrl/singer/epname/webUrl - onEnded 重赋 src 实现循环 (BgAudio 无原生 loop 属性) - onPause 同步 isPlaying,用户从锁屏控制条点暂停 UI 自动同步 - 暴露 playLoop(id)/pause/resume/stop API metronome.vue 改造: - 切换为 useMetronomeBg,移除右下角微调和拍号选择 (预合成 mp3 改不了 BPM/拍号,要么砍要么扩 9 个 mp3) - 中央圆按钮逻辑: · 未选档位 → 默认开"健走" · 选了档位且在播 → 暂停 · 选了档位且暂停 → 恢复 - 视频 playbackRate 跟当前档位 BPM 同步(80→0.65× / 110→0.89× / 130→1.05×) - 加底部提示"🔒 锁屏可继续播放,放兜里走也能听到" - onHide 不 stop,onUnmounted 才 stop(避免锁屏控制条挂死) 副作用: - 锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(老人友好) - 切档位有 200~500ms 延迟 (用户主动操作可接受) - 不再支持任意 BPM 微调 (3 档已覆盖大部分场景) 旧的 useMetronome.ts 暂时保留,如 BgAudio 真机验证 OK 再清理。 Co-authored-by: Cursor <cursoragent@cursor.com>
197 lines
5.7 KiB
TypeScript
197 lines
5.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
|
||
}
|
||
|
||
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||
{
|
||
id: 'slow',
|
||
bpm: 80,
|
||
accent: 2,
|
||
src: '/training/static/audio/loop_80bpm_2.mp3',
|
||
label: '慢走 80 BPM',
|
||
},
|
||
{
|
||
id: 'normal',
|
||
bpm: 110,
|
||
accent: 2,
|
||
src: '/training/static/audio/loop_110bpm_2.mp3',
|
||
label: '健走 110 BPM',
|
||
},
|
||
{
|
||
id: 'brisk',
|
||
bpm: 130,
|
||
accent: 2,
|
||
src: '/training/static/audio/loop_130bpm_2.mp3',
|
||
label: '快走 130 BPM',
|
||
},
|
||
]
|
||
|
||
const COVER_URL = '/training/static/audio/cover.jpg'
|
||
|
||
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 = '健走配速'
|
||
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,
|
||
}
|
||
}
|