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>
478 lines
12 KiB
Vue
478 lines
12 KiB
Vue
<template>
|
|
<view class="page" :style="rootStyle">
|
|
<!-- ===== 主舞台:节拍器圆 ===== -->
|
|
<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 }">
|
|
<text class="bpm-num">{{ currentBpm }}</text>
|
|
<text class="bpm-label">BPM</text>
|
|
<view class="ctrl">
|
|
<view v-if="isPlaying" class="icon-pause">
|
|
<view class="bar" />
|
|
<view class="bar" />
|
|
</view>
|
|
<view v-else class="icon-play" />
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
|
|
<view class="walker">
|
|
<view class="walker-box">
|
|
<video
|
|
id="runner-video"
|
|
class="runner-video"
|
|
:src="RUNNER_VIDEO_URL"
|
|
:muted="true"
|
|
:loop="true"
|
|
:autoplay="true"
|
|
:show-controls="false"
|
|
:show-fullscreen-btn="false"
|
|
:show-play-btn="false"
|
|
:show-center-play-btn="false"
|
|
:enable-progress-gesture="false"
|
|
:show-mute-btn="false"
|
|
:picture-in-picture-mode="[]"
|
|
object-fit="contain"
|
|
@error="onVideoError"
|
|
@loadedmetadata="onVideoLoaded"
|
|
/>
|
|
<!-- #ifdef MP-WEIXIN -->
|
|
<cover-view class="pip-mask"> </cover-view>
|
|
<!-- #endif -->
|
|
</view>
|
|
</view>
|
|
|
|
<!-- ===== 推荐档位(3 选 1) ===== -->
|
|
<view class="presets">
|
|
<view
|
|
v-for="p in LOOP_PRESETS"
|
|
:key="p.id"
|
|
class="preset"
|
|
:class="{ active: currentLoop === p.id }"
|
|
@click="onPresetTap(p.id)"
|
|
>
|
|
<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">{{ presetDesc(p.id) }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 提示信息(锁屏可继续播放) -->
|
|
<view class="hint">
|
|
<text>🔒 锁屏可继续播放,放兜里走也能听到</text>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
|
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
|
|
|
/**
|
|
* 跑步动画视频(腾讯云 COS CDN)
|
|
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
|
|
* 否则正式发布版会被拦截(开发版默认不校验)
|
|
*/
|
|
const RUNNER_VIDEO_URL =
|
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
|
|
|
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
|
const VIDEO_BASE_BPM = 124
|
|
|
|
const { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS } =
|
|
useMetronomeBg()
|
|
|
|
/* 档位元数据(图标/名称/描述) */
|
|
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 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 })
|
|
} else {
|
|
resume()
|
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
}
|
|
}
|
|
|
|
/* 档位卡片:
|
|
- 点未选档位 → 切到该档位并播放
|
|
- 点当前档位 → 切到暂停/恢复 */
|
|
const onPresetTap = (id: LoopId) => {
|
|
playLoop(id)
|
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
}
|
|
|
|
/* ============================================================
|
|
* 视频实例与 BPM 同步
|
|
* ============================================================ */
|
|
let videoCtx: UniApp.VideoContext | null = null
|
|
|
|
const playbackRate = computed(() => {
|
|
const rate = currentBpm.value / VIDEO_BASE_BPM
|
|
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
|
})
|
|
|
|
onMounted(() => {
|
|
videoCtx = uni.createVideoContext('runner-video')
|
|
setTimeout(() => videoCtx?.pause(), 50)
|
|
})
|
|
|
|
const onVideoError = (e: any) => {
|
|
console.error('[runner-video] error:', e?.detail || e)
|
|
}
|
|
|
|
const onVideoLoaded = (e: any) => {
|
|
console.log('[runner-video] metadata loaded:', e?.detail)
|
|
try {
|
|
videoCtx?.playbackRate?.(playbackRate.value)
|
|
} catch (_) {}
|
|
}
|
|
|
|
watch(isPlaying, (playing) => {
|
|
if (playing) {
|
|
videoCtx?.play()
|
|
} else {
|
|
videoCtx?.pause()
|
|
}
|
|
})
|
|
|
|
watch(playbackRate, (rate) => {
|
|
try {
|
|
videoCtx?.playbackRate?.(rate)
|
|
} catch (_) {}
|
|
})
|
|
|
|
const rootStyle = computed(() => ({
|
|
'--beat-duration': `${intervalMs.value}ms`,
|
|
}))
|
|
|
|
onShow(() => {
|
|
/* BgAudio 全局单例,无需特殊预热,首次 setSrc 时自动初始化 */
|
|
})
|
|
|
|
/**
|
|
* onHide 故意不 stop:
|
|
* - 切到后台/锁屏/小窗 都会触发 onHide
|
|
* - 配合 BackgroundAudioManager,音频会通过系统层继续播放
|
|
* - 用户能从锁屏控制条主动暂停(通过 onPause 回调同步 UI)
|
|
*/
|
|
onHide(() => {
|
|
/* 不停止,让节拍声后台继续 */
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
/* 页面被销毁(navigateBack/重启)时才真正停止
|
|
否则锁屏控制条会一直挂着无主的"节拍器" */
|
|
if (isPlaying.value) stop()
|
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
/* ============================================================
|
|
* 设计 token —— 浅色清爽
|
|
* ============================================================ */
|
|
$bg-color: #f8fafc;
|
|
$card-bg: #ffffff;
|
|
$card-border: rgba(15, 23, 42, 0.06);
|
|
$text-1: #0f172a;
|
|
$text-2: #475569;
|
|
$text-3: #94a3b8;
|
|
$brand: #10b981;
|
|
$brand-soft: #d1fae5;
|
|
$brand-deep: #047857;
|
|
$radius-card: 24rpx;
|
|
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
|
|
|
/* ============================================================
|
|
* 页面容器
|
|
* ============================================================ */
|
|
.page {
|
|
position: relative;
|
|
height: 100vh;
|
|
box-sizing: border-box;
|
|
padding: 30rpx 28rpx 80rpx;
|
|
background: $bg-color;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 22rpx;
|
|
color: $text-1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ============================================================
|
|
* 节拍器主舞台
|
|
* ============================================================ */
|
|
.stage {
|
|
position: relative;
|
|
width: 480rpx;
|
|
height: 480rpx;
|
|
margin-top: 16rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&:active .core {
|
|
transform: scale(0.97);
|
|
}
|
|
}
|
|
|
|
.ring {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 50%;
|
|
border: 3rpx solid rgba(16, 185, 129, 0.12);
|
|
pointer-events: none;
|
|
|
|
&.playing {
|
|
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
|
}
|
|
&.r2.playing {
|
|
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
|
|
}
|
|
}
|
|
@keyframes ring-pulse {
|
|
0% {
|
|
transform: scale(0.94);
|
|
opacity: 0.85;
|
|
border-color: rgba(16, 185, 129, 0.4);
|
|
}
|
|
100% {
|
|
transform: scale(1.18);
|
|
opacity: 0;
|
|
border-color: rgba(16, 185, 129, 0);
|
|
}
|
|
}
|
|
|
|
.core {
|
|
position: relative;
|
|
width: 380rpx;
|
|
height: 380rpx;
|
|
border-radius: 50%;
|
|
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow:
|
|
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
|
|
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
|
|
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
|
|
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
|
|
|
|
&.playing {
|
|
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
|
}
|
|
|
|
.bpm-num {
|
|
font-size: 168rpx;
|
|
font-weight: 800;
|
|
color: #fff;
|
|
line-height: 1;
|
|
letter-spacing: -2rpx;
|
|
font-variant-numeric: tabular-nums;
|
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
|
|
}
|
|
|
|
.bpm-label {
|
|
font-size: 22rpx;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
letter-spacing: 6rpx;
|
|
margin-top: 10rpx;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.ctrl {
|
|
margin-top: 22rpx;
|
|
height: 64rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
@keyframes core-beat {
|
|
0%,
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
transform: scale(1.04);
|
|
}
|
|
}
|
|
|
|
/* 播放/暂停 icon */
|
|
.icon-play {
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 52rpx solid #fff;
|
|
border-top: 32rpx solid transparent;
|
|
border-bottom: 32rpx solid transparent;
|
|
margin-left: 12rpx;
|
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
|
}
|
|
.icon-pause {
|
|
display: flex;
|
|
gap: 16rpx;
|
|
height: 60rpx;
|
|
|
|
.bar {
|
|
width: 16rpx;
|
|
height: 100%;
|
|
background: #fff;
|
|
border-radius: 4rpx;
|
|
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
* 跑步小人
|
|
* ============================================================ */
|
|
.walker {
|
|
width: 100%;
|
|
height: 260rpx;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.walker-box {
|
|
position: relative;
|
|
width: 260rpx;
|
|
height: 260rpx;
|
|
background: #e8ecf2;
|
|
border-radius: 24rpx;
|
|
overflow: hidden;
|
|
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
|
|
}
|
|
|
|
.runner-video {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: #e8ecf2;
|
|
}
|
|
|
|
.pip-mask {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 80rpx;
|
|
height: 56rpx;
|
|
background-color: #dde2eb;
|
|
border-bottom-left-radius: 18rpx;
|
|
border-top-right-radius: 24rpx;
|
|
}
|
|
|
|
/* ============================================================
|
|
* 三档位推荐
|
|
* ============================================================ */
|
|
.presets {
|
|
width: 100%;
|
|
display: flex;
|
|
gap: 16rpx;
|
|
}
|
|
|
|
.preset {
|
|
flex: 1;
|
|
background: $card-bg;
|
|
border: 2rpx solid $card-border;
|
|
border-radius: $radius-card;
|
|
padding: 22rpx 12rpx 20rpx;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6rpx;
|
|
box-shadow: $shadow-sm;
|
|
transition: all 0.18s;
|
|
|
|
.preset-icon {
|
|
font-size: 44rpx;
|
|
line-height: 1;
|
|
margin-bottom: 6rpx;
|
|
}
|
|
.preset-name {
|
|
font-size: 30rpx;
|
|
font-weight: 700;
|
|
color: $text-1;
|
|
letter-spacing: 2rpx;
|
|
}
|
|
.preset-bpm {
|
|
font-size: 24rpx;
|
|
color: $text-2;
|
|
font-weight: 600;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.preset-desc {
|
|
font-size: 20rpx;
|
|
color: $text-3;
|
|
letter-spacing: 1rpx;
|
|
margin-top: 2rpx;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.97);
|
|
}
|
|
|
|
&.active {
|
|
background: $brand-soft;
|
|
border-color: $brand;
|
|
box-shadow:
|
|
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
|
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
|
|
|
.preset-name {
|
|
color: $brand-deep;
|
|
}
|
|
.preset-bpm {
|
|
color: $brand-deep;
|
|
}
|
|
.preset-desc {
|
|
color: $brand;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
* 底部提示
|
|
* ============================================================ */
|
|
.hint {
|
|
margin-top: 8rpx;
|
|
font-size: 22rpx;
|
|
color: $text-3;
|
|
letter-spacing: 1rpx;
|
|
|
|
text {
|
|
line-height: 1.6;
|
|
}
|
|
}
|
|
</style>
|