feat(metronome): 升级到 BackgroundAudioManager,iOS 真后台播放

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>
This commit is contained in:
2026-05-26 09:39:33 +08:00
co-authored by Cursor
parent b93313d935
commit 0e46bf8e0d
6 changed files with 263 additions and 222 deletions
+67 -222
View File
@@ -1,11 +1,11 @@
<template>
<view class="page" :style="rootStyle">
<!-- ===== 主舞台:节拍器圆 ===== -->
<view class="stage" @click="togglePlay">
<view class="stage" @click="onCenterTap">
<view class="ring r1" :class="{ playing: isPlaying }" />
<view class="ring r2" :class="{ playing: isPlaying }" />
<view class="core" :class="{ playing: isPlaying, accent: isAccent }">
<text class="bpm-num">{{ bpm }}</text>
<view class="core" :class="{ playing: isPlaying }">
<text class="bpm-num">{{ currentBpm }}</text>
<text class="bpm-label">BPM</text>
<view class="ctrl">
<view v-if="isPlaying" class="icon-pause">
@@ -38,10 +38,8 @@
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
/>
<!-- 微信原生 video 右上角的 PIP/小窗按钮无法靠属性彻底关掉,
cover-view 覆盖一个跟背景同色的小色块盖住它 -->
<!-- #ifdef MP-WEIXIN -->
<!-- <cover-view class="pip-mask">&nbsp;</cover-view> -->
<cover-view class="pip-mask">&nbsp;</cover-view>
<!-- #endif -->
</view>
</view>
@@ -49,39 +47,22 @@
<!-- ===== 推荐档位(3 1) ===== -->
<view class="presets">
<view
v-for="p in PRESETS"
v-for="p in LOOP_PRESETS"
:key="p.id"
class="preset"
:class="{ active: activePreset === p.id }"
@click="applyPreset(p)"
:class="{ active: currentLoop === p.id }"
@click="onPresetTap(p.id)"
>
<text class="preset-icon">{{ p.icon }}</text>
<text class="preset-name">{{ p.name }}</text>
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
<text class="preset-name">{{ presetName(p.id) }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</text>
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
</view>
</view>
<!-- ===== 右下角微调(不显眼) ===== -->
<view class="quick-adjust">
<view class="qa-group">
<view class="qa-btn" @click.stop="adjustBpm(-1)"></view>
<text class="qa-label">微调</text>
<view class="qa-btn" @click.stop="adjustBpm(1)">+</view>
</view>
<view class="qa-divider" />
<view class="qa-group">
<view
v-for="m in METER_OPTIONS"
:key="m.value"
class="qa-meter"
:class="{ active: accentEvery === m.value }"
@click.stop="onMeterChange(m.value)"
>
{{ m.label }}
</view>
<text class="qa-label"></text>
</view>
<!-- 提示信息(锁屏可继续播放) -->
<view class="hint">
<text>🔒 锁屏可继续播放,放兜里走也能听到</text>
</view>
</view>
</template>
@@ -89,7 +70,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronome } from '../hooks/useMetronome'
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
/**
* 跑步动画视频(腾讯云 COS CDN)
@@ -99,104 +80,63 @@ import { useMetronome } from '../hooks/useMetronome'
const RUNNER_VIDEO_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
/**
* 视频原速对应的 BPM 基准(参见 hooks 实现注释)
* playback-rate 范围 [0.5, 2.0]
*/
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
const VIDEO_BASE_BPM = 124
/**
* 推荐档位:覆盖大多数老人健走场景,一键切换 BPM + 拍号
* 不在档位的中间数值用右下角微调即可
*/
const PRESETS = [
{
id: 'slow',
icon: '🚶',
name: '慢走',
bpm: 80,
accent: 2,
desc: '热身 · 恢复',
},
{
id: 'normal',
icon: '🏃‍♀️',
name: '健走',
bpm: 110,
accent: 2,
desc: '日常 · 通勤',
},
{
id: 'brisk',
icon: '🏃',
name: '快走',
bpm: 130,
accent: 2,
desc: '提速 · 燃脂',
},
] as const
const { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS } =
useMetronomeBg()
const METER_OPTIONS = [
{ label: '2', value: 2 },
{ label: '3', value: 3 },
{ label: '4', value: 4 },
]
/* 档位元数据(图标/名称/描述) */
const presetIcon = (id: LoopId) =>
id === 'slow' ? '🚶' : id === 'normal' ? '🏃‍♀️' : '🏃'
const presetName = (id: LoopId) =>
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
const presetDesc = (id: LoopId) =>
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
const metronome = useMetronome({
initialBpm: 80,
accentEvery: 4,
clickSrc: '/training/static/audio/click.mp3',
accentSrc: '/training/static/audio/click-wood.mp3',
})
const {
bpm,
isPlaying,
isAccent,
accentEvery,
intervalMs,
start,
stop,
setBpm,
setAccentEvery,
preload,
} = metronome
const currentPreset = computed(() =>
currentLoop.value
? LOOP_PRESETS.find((p) => p.id === currentLoop.value) ?? null
: null,
)
const adjustBpm = (delta: number) => {
setBpm(bpm.value + delta)
}
const currentBpm = computed(() => currentPreset.value?.bpm ?? 110)
const intervalMs = computed(() => 60000 / currentBpm.value)
const onMeterChange = (val: number) => {
setAccentEvery(val)
}
const togglePlay = () => {
/* 中央圆按钮:
- 没选档位 → 默认开"健走"
- 选了档位且在播 → 暂停
- 选了档位且暂停 → 恢复 */
const onCenterTap = () => {
if (!currentLoop.value) {
playLoop('normal')
uni.setKeepScreenOn({ keepScreenOn: true })
return
}
if (isPlaying.value) {
stop()
pause()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
start()
resume()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
/* 当前命中的档位(BPM + 拍号都匹配才算选中) */
const activePreset = computed(() => {
const hit = PRESETS.find(
(p) => p.bpm === bpm.value && p.accent === accentEvery.value,
)
return hit?.id ?? null
})
const applyPreset = (p: (typeof PRESETS)[number]) => {
setBpm(p.bpm)
setAccentEvery(p.accent)
/* 档位卡片:
- 点未选档位 → 切到该档位并播放
- 点当前档位 → 切到暂停/恢复 */
const onPresetTap = (id: LoopId) => {
playLoop(id)
uni.setKeepScreenOn({ keepScreenOn: true })
}
/* 视频实例与 BPM 同步 */
/* ============================================================
* 视频实例与 BPM 同步
* ============================================================ */
let videoCtx: UniApp.VideoContext | null = null
const playbackRate = computed(() => {
const rate = bpm.value / VIDEO_BASE_BPM
const rate = currentBpm.value / VIDEO_BASE_BPM
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
})
@@ -207,11 +147,6 @@ onMounted(() => {
const onVideoError = (e: any) => {
console.error('[runner-video] error:', e?.detail || e)
uni.showToast({
title: '视频加载失败,请用真机预览',
icon: 'none',
duration: 2500,
})
}
const onVideoLoaded = (e: any) => {
@@ -237,27 +172,25 @@ watch(playbackRate, (rate) => {
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
'--step-duration': `${intervalMs.value}ms`,
}))
onShow(() => {
preload()
/* BgAudio 全局单例,无需特殊预热,首次 setSrc 时自动初始化 */
})
/**
* onHide 故意不 stop:
* - 切到后台 / 微信"小窗" 都会触发 onHide
* - 配合 pages.json 的 requiredBackgroundModes:["audio"],
* 小程序退到后台后节拍声仍然可以继续(健走时把手机放兜里也能听到响)
* - 真正离开节拍器页(navigateBack/重启) 会 onUnload+onUnmounted,
* 在那里清理屏幕常亮
* - 切到后台/锁屏/小窗 都会触发 onHide
* - 配合 BackgroundAudioManager,音频会通过系统层继续播放
* - 用户能从锁屏控制条主动暂停(通过 onPause 回调同步 UI)
*/
onHide(() => {
/* 不再主动 stop, 让节拍器在后台继续工作 */
/* 不停止,让节拍后台继续 */
})
onUnmounted(() => {
/* 页面卸载才真正停 + 释放屏幕常亮 */
/* 页面被销毁(navigateBack/重启)时才真正停止
否则锁屏控制条会一直挂着无主的"节拍器" */
if (isPlaying.value) stop()
uni.setKeepScreenOn({ keepScreenOn: false })
})
@@ -276,12 +209,8 @@ $text-3: #94a3b8;
$brand: #10b981;
$brand-soft: #d1fae5;
$brand-deep: #047857;
$accent: #f59e0b;
$accent-soft: #fef3c7;
$radius-card: 24rpx;
$radius-btn: 18rpx;
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
$shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
/* ============================================================
* 页面容器
@@ -365,14 +294,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
}
&.playing.accent {
background: linear-gradient(140deg, #fbbf24 0%, #d97706 100%);
box-shadow:
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.22),
0 16rpx 40rpx rgba(245, 158, 11, 0.4);
}
.bpm-num {
font-size: 168rpx;
font-weight: 800;
@@ -434,7 +355,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
}
/* ============================================================
* 跑步小人(无边框,跟白底融合)
* 跑步小人
* ============================================================ */
.walker {
width: 100%;
@@ -445,9 +366,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
}
.walker-box {
/* 1:1 矩形,跟 video 内容比例一致,消除左右黑边
background 设成 video 内容右上角的浅灰白色,
万一还有 1rpx 缝隙也不会出现刺眼黑边 */
position: relative;
width: 260rpx;
height: 260rpx;
@@ -460,14 +378,9 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
.runner-video {
width: 100%;
height: 100%;
/* 微信 native video 自身默认是 #000 黑底,父容器 bg 在 web 看不到,
只能让 video 占满 + 让父 bg 跟 video 内容色融合 */
background: #e8ecf2;
}
/* 覆盖在 video 右上角原生 PIP/小窗按钮上的色块
颜色取 video 内容右上角的浅灰白渐变(#dde2eb 左右),让色块融入视频画面
仅小程序端有效(cover-view 是小程序唯一能盖住 native 组件的元素) */
.pip-mask {
position: absolute;
top: 0;
@@ -549,84 +462,16 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
}
/* ============================================================
* 右下角微调(不显眼)
* 底部提示
* ============================================================ */
.quick-adjust {
position: absolute;
right: 28rpx;
bottom: 22rpx;
display: flex;
align-items: center;
gap: 14rpx;
padding: 10rpx 18rpx;
background: rgba(255, 255, 255, 0.7);
border: 1rpx solid rgba(15, 23, 42, 0.05);
border-radius: 999rpx;
backdrop-filter: blur(10rpx);
opacity: 0.7;
transition: opacity 0.2s;
&:active {
opacity: 1;
}
}
.qa-group {
display: flex;
align-items: center;
gap: 6rpx;
}
.qa-label {
font-size: 20rpx;
color: $text-3;
margin: 0 4rpx;
letter-spacing: 1rpx;
}
.qa-btn {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(15, 23, 42, 0.05);
color: $text-2;
font-size: 26rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
&:active {
background: rgba(16, 185, 129, 0.18);
color: $brand-deep;
}
}
.qa-meter {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 999rpx;
.hint {
margin-top: 8rpx;
font-size: 22rpx;
color: $text-3;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: all 0.15s;
letter-spacing: 1rpx;
&.active {
background: $brand;
color: #fff;
box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4);
text {
line-height: 1.6;
}
}
.qa-divider {
width: 1rpx;
height: 22rpx;
background: rgba(15, 23, 42, 0.08);
}
</style>