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:
@@ -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"> </cover-view> -->
|
||||
<cover-view class="pip-mask"> </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>
|
||||
|
||||
Reference in New Issue
Block a user