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 格式
This commit is contained in:
2026-05-26 11:15:15 +08:00
parent 0e46bf8e0d
commit 3325be0622
12 changed files with 380 additions and 107 deletions
+325 -67
View File
@@ -50,7 +50,7 @@
v-for="p in LOOP_PRESETS"
:key="p.id"
class="preset"
:class="{ active: currentLoop === p.id }"
:class="{ active: mode === 'preset' && currentLoop === p.id }"
@click="onPresetTap(p.id)"
>
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
@@ -60,9 +60,46 @@
</view>
</view>
<!-- 提示信息(锁屏可继续播放) -->
<view class="hint">
<text>🔒 锁屏可继续播放,放兜里走也能听到</text>
<!-- ===== 自定义节奏(高级折叠区) ===== -->
<view v-if="customExpanded" class="custom-panel">
<view class="custom-warn">
<text class="warn-dot"></text>
<text class="warn-text">前台模式 · 切后台会暂停</text>
</view>
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ customBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
<!-- 底部行:自定义入口 + 提示 -->
<view class="bottom-row">
<text class="bottom-link" @click="onToggleCustom">
{{ customExpanded ? ' 收起自定义' : ' 自定义节奏' }}
</text>
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
</view>
</view>
</template>
@@ -71,6 +108,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
import { useMetronome } from '../hooks/useMetronome'
/**
* 跑步动画视频(腾讯云 COS CDN)
@@ -83,10 +121,54 @@ const RUNNER_VIDEO_URL =
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
const VIDEO_BASE_BPM = 124
const { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS } =
useMetronomeBg()
/* ============================================================
* 双引擎设计
* ------------------------------------------------------------
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
* - 切模式时停掉对侧引擎,避免双声道叠加
* ============================================================ */
type Mode = 'preset' | 'custom'
const mode = ref<Mode>('preset')
const customExpanded = ref<boolean>(false)
/* 档位元数据(图标/名称/描述) */
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
const CLICK_WOOD_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
const bg = useMetronomeBg()
const fg = useMetronome({
initialBpm: 100,
accentEvery: 2,
clickSrc: CLICK_WOOD_URL,
accentSrc: CLICK_WOOD_URL,
poolSize: 4,
})
const { LOOP_PRESETS } = bg
const currentLoop = bg.currentLoop
const customBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
const isPlaying = computed(() =>
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
)
const currentBpm = computed(() => {
if (mode.value === 'preset') {
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
return p?.bpm ?? 110
}
return fg.bpm.value
})
const intervalMs = computed(() => 60000 / currentBpm.value)
/* ============================================================
* 档位元数据
* ============================================================ */
const presetIcon = (id: LoopId) =>
id === 'slow' ? '🚶' : id === 'normal' ? '🏃‍♀️' : '🏃'
const presetName = (id: LoopId) =>
@@ -94,42 +176,83 @@ const presetName = (id: LoopId) =>
const presetDesc = (id: LoopId) =>
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
const currentPreset = computed(() =>
currentLoop.value
? LOOP_PRESETS.find((p) => p.id === currentLoop.value) ?? null
: null,
)
const currentBpm = computed(() => currentPreset.value?.bpm ?? 110)
const intervalMs = computed(() => 60000 / currentBpm.value)
/* 中央圆按钮:
- 没选档位 → 默认开"健走"
- 选了档位且在播 → 暂停
- 选了档位且暂停 → 恢复 */
/* ============================================================
* 中央圆按钮:在两个模式下分别工作
* ============================================================ */
const onCenterTap = () => {
if (!currentLoop.value) {
playLoop('normal')
uni.setKeepScreenOn({ keepScreenOn: true })
return
}
if (isPlaying.value) {
pause()
uni.setKeepScreenOn({ keepScreenOn: false })
if (mode.value === 'preset') {
if (!currentLoop.value) {
bg.playLoop('normal')
uni.setKeepScreenOn({ keepScreenOn: true })
return
}
if (bg.isPlaying.value) {
bg.pause()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
bg.resume()
uni.setKeepScreenOn({ keepScreenOn: true })
}
} else {
resume()
uni.setKeepScreenOn({ keepScreenOn: true })
/* custom 模式 */
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
}
/* 档位卡片:
- 点未选档位 → 切到该档位并播放
- 点当前档位 → 切到暂停/恢复 */
/* ============================================================
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
* ============================================================ */
const onPresetTap = (id: LoopId) => {
playLoop(id)
if (mode.value === 'custom') {
fg.stop()
mode.value = 'preset'
customExpanded.value = false
}
bg.playLoop(id)
uni.setKeepScreenOn({ keepScreenOn: true })
}
/* ============================================================
* 自定义折叠区切换
* ============================================================ */
const onToggleCustom = () => {
if (!customExpanded.value) {
/* 展开:进入 custom 模式 + 停掉 preset */
if (bg.isPlaying.value || currentLoop.value) {
bg.stop()
}
mode.value = 'custom'
/* 把当前显示 BPM 带过去当起始值 */
const seed =
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
fg.setBpm(seed)
fg.setAccentEvery(2)
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
fg.preload()
customExpanded.value = true
} else {
/* 收起:回到 preset,停掉 fg */
fg.stop()
mode.value = 'preset'
customExpanded.value = false
uni.setKeepScreenOn({ keepScreenOn: false })
}
}
const onBpmDelta = (delta: number) => {
fg.setBpm(fg.bpm.value + delta)
}
const onAccentSelect = (n: number) => {
fg.setAccentEvery(n)
}
/* ============================================================
* 视频实例与 BPM 同步
* ============================================================ */
@@ -175,23 +298,28 @@ const rootStyle = computed(() => ({
}))
onShow(() => {
/* BgAudio 全局单例,无需特殊预热,首次 setSrc 时自动初始化 */
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
if (mode.value === 'custom') {
fg.preload()
}
})
/**
* onHide 故意不 stop:
* - 切到后台/锁屏/小窗 都会触发 onHide
* - 配合 BackgroundAudioManager,音频会通过系统层继续播放
* - 用户能从锁屏控制条主动暂停(通过 onPause 回调同步 UI)
* onHide:差异化处理
* - preset 模式: 不停,BgAudio 接管后台播放
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
*/
onHide(() => {
/* 不停止,让节拍声后台继续 */
if (mode.value === 'custom') {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
/* 页面销毁(navigateBack/重启)时才真正停止
否则锁屏控制条会一直挂着无主的"节拍器" */
if (isPlaying.value) stop()
/* 页面销毁时全部清理 */
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
if (mode.value === 'custom') fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
@@ -209,6 +337,8 @@ $text-3: #94a3b8;
$brand: #10b981;
$brand-soft: #d1fae5;
$brand-deep: #047857;
$warn: #f59e0b;
$warn-soft: #fef3c7;
$radius-card: 24rpx;
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
@@ -217,16 +347,15 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
* ============================================================ */
.page {
position: relative;
height: 100vh;
min-height: 100vh;
box-sizing: border-box;
padding: 30rpx 28rpx 80rpx;
padding: 30rpx 28rpx 50rpx;
background: $bg-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 22rpx;
gap: 20rpx;
color: $text-1;
overflow: hidden;
}
/* ============================================================
@@ -236,7 +365,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
position: relative;
width: 480rpx;
height: 480rpx;
margin-top: 16rpx;
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -359,7 +488,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
* ============================================================ */
.walker {
width: 100%;
height: 260rpx;
height: 240rpx;
display: flex;
justify-content: center;
align-items: center;
@@ -367,8 +496,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.walker-box {
position: relative;
width: 260rpx;
height: 260rpx;
width: 240rpx;
height: 240rpx;
background: #e8ecf2;
border-radius: 24rpx;
overflow: hidden;
@@ -398,7 +527,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
.presets {
width: 100%;
display: flex;
gap: 16rpx;
gap: 14rpx;
}
.preset {
@@ -406,33 +535,33 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 22rpx 12rpx 20rpx;
padding: 18rpx 8rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
gap: 4rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.preset-icon {
font-size: 44rpx;
font-size: 40rpx;
line-height: 1;
margin-bottom: 6rpx;
margin-bottom: 4rpx;
}
.preset-name {
font-size: 30rpx;
font-size: 28rpx;
font-weight: 700;
color: $text-1;
letter-spacing: 2rpx;
}
.preset-bpm {
font-size: 24rpx;
font-size: 22rpx;
color: $text-2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.preset-desc {
font-size: 20rpx;
font-size: 18rpx;
color: $text-3;
letter-spacing: 1rpx;
margin-top: 2rpx;
@@ -462,16 +591,145 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 底部提示
* 自定义节奏面板(仅 customExpanded 时显示)
* ============================================================ */
.hint {
margin-top: 8rpx;
font-size: 22rpx;
color: $text-3;
letter-spacing: 1rpx;
.custom-panel {
width: 100%;
background: $card-bg;
border: 2rpx solid rgba(245, 158, 11, 0.25);
border-radius: $radius-card;
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
padding: 20rpx 24rpx 22rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
text {
line-height: 1.6;
.custom-warn {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 4rpx;
.warn-dot {
font-size: 18rpx;
color: $warn;
}
.warn-text {
font-size: 22rpx;
color: #b45309;
letter-spacing: 0.5rpx;
font-weight: 600;
}
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
.row-label {
font-size: 24rpx;
color: $text-2;
font-weight: 600;
letter-spacing: 1rpx;
flex-shrink: 0;
width: 70rpx;
}
}
.bpm-stepper {
display: flex;
align-items: center;
gap: 6rpx;
flex: 1;
justify-content: flex-end;
.step-btn {
min-width: 56rpx;
height: 52rpx;
line-height: 52rpx;
text-align: center;
font-size: 22rpx;
color: $text-2;
background: #f1f5f9;
border-radius: 10rpx;
font-weight: 600;
padding: 0 10rpx;
font-variant-numeric: tabular-nums;
&:active {
background: #e2e8f0;
transform: scale(0.94);
}
}
.step-val {
min-width: 84rpx;
height: 52rpx;
line-height: 52rpx;
text-align: center;
font-size: 30rpx;
font-weight: 800;
color: $text-1;
font-variant-numeric: tabular-nums;
background: $warn-soft;
border-radius: 10rpx;
}
}
.meter-tabs {
display: flex;
gap: 8rpx;
.meter-tab {
height: 52rpx;
line-height: 52rpx;
padding: 0 18rpx;
font-size: 22rpx;
color: $text-2;
background: #f1f5f9;
border-radius: 10rpx;
font-weight: 600;
letter-spacing: 1rpx;
&.active {
background: $warn;
color: #fff;
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
}
&:active {
transform: scale(0.94);
}
}
}
/* ============================================================
* 底部行(自定义入口 + 锁屏提示,横向排布)
* ============================================================ */
.bottom-row {
width: 100%;
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6rpx;
}
.bottom-link {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
padding: 8rpx 4rpx;
&:active {
color: $text-2;
}
}
.bottom-hint {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
}
</style>