feat(metronome): walker 区改用 MP4 视频,playbackRate 跟 BPM 同步

将之前的 PNG runner + CSS 律动方案,升级为真正的卡通 walk cycle 视频:

- 删除 runner.png(静态图无法表现关节运动)
- 新增 runner.mp4(LottieFiles 卡通跑步动画,150x150, 60fps, 27KB)
- walker 区从 image + CSS 抖动 改为 video + playback-rate 调速
- 视频原速基准 124 BPM(0.97s 一个 walk cycle = 2 步)
- playbackRate = clamp(bpm/124, 0.5, 2.0),贴合微信小程序限制
- 视频与节拍器同步策略:
  · watch isPlaying → 自动 play/pause
  · watch playbackRate → 调用 videoCtx.playbackRate API 兜底
  · onMounted 默认暂停,等用户点开始才播
- 视频白底跟暗色卡片冲突,改用"嵌入式高亮卡片"设计:
  · runner-card 白底 + 32rpx 圆角 + 阴影,像专业健康 APP
  · video object-fit contain 保证不被裁切
- 移除冗余的 .ground 地面线样式

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 16:15:46 +08:00
co-authored by Cursor
parent a3ee72b34d
commit 867a6a97cf
3 changed files with 67 additions and 95 deletions
+67 -95
View File
@@ -28,15 +28,24 @@
</view>
<view class="walker" :class="{ playing: isPlaying }">
<view class="runner-wrap">
<image
class="runner"
src="/static/training/runner.png"
mode="aspectFit"
<view class="runner-card">
<video
id="runner-video"
class="runner-video"
src="/static/training/runner.mp4"
:muted="true"
:loop="true"
:autoplay="true"
:playback-rate="playbackRate"
:show-controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:enable-progress-gesture="false"
:show-mute-btn="false"
object-fit="contain"
/>
<view class="runner-shadow" />
</view>
<view class="ground" />
</view>
</view>
@@ -78,10 +87,18 @@
</template>
<script setup lang="ts">
import { computed, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronome } from '@/hooks/useMetronome'
/**
* 视频原速对应的 BPM 基准
* runner.mp4 时长 0.97s, 一个 walk cycle (左右脚各一次) ≈ 124 BPM 步频
* 用户调速时 playbackRate = bpm / VIDEO_BASE_BPM
* 微信小程序限制 playback-rate 范围 [0.5, 2.0]
*/
const VIDEO_BASE_BPM = 124
const METER_OPTIONS = [
{ label: '2', value: 2 },
{ label: '3', value: 3 },
@@ -126,6 +143,36 @@ const togglePlay = () => {
}
}
/* 视频实例与 BPM 同步 */
let videoCtx: UniApp.VideoContext | null = null
const playbackRate = computed(() => {
const rate = bpm.value / VIDEO_BASE_BPM
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
})
onMounted(() => {
videoCtx = uni.createVideoContext('runner-video')
/* 进页面默认让视频保持暂停,跟节拍器 isPlaying 状态同步 */
setTimeout(() => videoCtx?.pause(), 50)
})
/* 节拍器状态变化 → 同步视频播放/暂停 */
watch(isPlaying, (playing) => {
if (playing) {
videoCtx?.play()
} else {
videoCtx?.pause()
}
})
/* BPM 变化 → 调整视频播放速度(部分基础库需要手动 API 调用) */
watch(playbackRate, (rate) => {
try {
videoCtx?.playbackRate?.(rate)
} catch (_) {}
})
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
'--step-duration': `${intervalMs.value}ms`,
@@ -412,103 +459,28 @@ $radius-btn: 20rpx;
align-items: flex-end;
}
.walker .ground {
position: absolute;
bottom: 22rpx;
left: 10%;
right: 10%;
height: 1rpx;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.18) 30%,
rgba(255, 255, 255, 0.18) 70%,
transparent 100%
);
}
/* ============================================================
* 跑步小人(静态 PNG + CSS 律动)
* 跑步小人视频(白卡嵌入)
* ============================================================ */
.runner-wrap {
.runner-card {
position: absolute;
bottom: 18rpx;
left: 50%;
transform: translateX(-50%);
width: 200rpx;
height: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
width: 220rpx;
height: 220rpx;
background: #fff;
border-radius: 32rpx;
overflow: hidden;
box-shadow:
0 12rpx 32rpx rgba(0, 0, 0, 0.25),
inset 0 0 0 1rpx rgba(255, 255, 255, 0.3);
}
.runner {
.runner-video {
width: 100%;
height: 100%;
transform-origin: bottom center;
/* 默认状态保持微妙的呼吸感,不至于完全静止 */
transition: filter 0.3s;
}
.runner-shadow {
position: absolute;
bottom: -8rpx;
width: 120rpx;
height: 12rpx;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.45),
transparent 70%
);
border-radius: 50%;
transform-origin: center;
}
/* 跟节拍同步:1 拍 1 次律动 */
.walker.playing .runner {
animation: runner-bob var(--step-duration, 750ms) ease-in-out infinite;
/* 高 BPM 时加速度模糊,让"跑得很快"有视觉表达 */
filter: drop-shadow(0 4rpx 12rpx rgba(0, 0, 0, 0.25));
}
.walker.playing .runner-shadow {
animation: runner-shadow-pulse var(--step-duration, 750ms) ease-in-out infinite;
}
/* 跑步律动:身体上下起伏 + 微妙左右摆动 */
@keyframes runner-bob {
0% {
transform: translateY(0) rotate(0deg) scale(1);
}
25% {
/* 第一拍着地半程,身体最低 */
transform: translateY(2rpx) rotate(-1.5deg) scale(1, 0.99);
}
50% {
/* 离地最高点,身体抬起 */
transform: translateY(-14rpx) rotate(0deg) scale(1, 1.01);
}
75% {
/* 另一脚着地半程,反向倾斜 */
transform: translateY(2rpx) rotate(1.5deg) scale(1, 0.99);
}
100% {
transform: translateY(0) rotate(0deg) scale(1);
}
}
@keyframes runner-shadow-pulse {
0%,
100% {
opacity: 0.5;
transform: scaleX(1);
}
50% {
/* 跳起时阴影变小变淡 */
opacity: 0.2;
transform: scaleX(0.7);
}
background: #fff;
}
/* ============================================================
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB