Files
zyt/TUICallKit-Vue3/training/pages/metronome.vue
T
longandCursor 3f999b312d tweak(metronome): 三档位拍数统一改为 2 拍
慢走/健走/快走 三档 accent 从 4 → 2:
- 走路场景天然是左-右双脚交替的 2 拍循环
- 重音落在每一步的"主脚"(默认左脚),节奏感更贴合步态
- 4 拍听感像跑步进行曲,2 拍才是健走/慢跑实际节奏

需要 3/4 拍号的可在右下角微调里手动切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:05:31 +08:00

626 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="rootStyle">
<!-- ===== 主舞台:节拍器圆 ===== -->
<view class="stage" @click="togglePlay">
<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>
<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"
/>
<!-- 微信原生 video 右上角的 PIP/小窗按钮无法靠属性彻底关掉,
cover-view 覆盖一个跟背景同色的小色块盖住它 -->
<!-- #ifdef MP-WEIXIN -->
<!-- <cover-view class="pip-mask">&nbsp;</cover-view> -->
<!-- #endif -->
</view>
</view>
<!-- ===== 推荐档位(3 1) ===== -->
<view class="presets">
<view
v-for="p in PRESETS"
:key="p.id"
class="preset"
:class="{ active: activePreset === p.id }"
@click="applyPreset(p)"
>
<text class="preset-icon">{{ p.icon }}</text>
<text class="preset-name">{{ p.name }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</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>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronome } from '../hooks/useMetronome'
/**
* 跑步动画视频(腾讯云 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 基准(参见 hooks 实现注释)
* 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 METER_OPTIONS = [
{ label: '2', value: 2 },
{ label: '3', value: 3 },
{ label: '4', value: 4 },
]
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 adjustBpm = (delta: number) => {
setBpm(bpm.value + delta)
}
const onMeterChange = (val: number) => {
setAccentEvery(val)
}
const togglePlay = () => {
if (isPlaying.value) {
stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
start()
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)
}
/* 视频实例与 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')
setTimeout(() => videoCtx?.pause(), 50)
})
const onVideoError = (e: any) => {
console.error('[runner-video] error:', e?.detail || e)
uni.showToast({
title: '视频加载失败,请用真机预览',
icon: 'none',
duration: 2500,
})
}
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`,
'--step-duration': `${intervalMs.value}ms`,
}))
onShow(() => {
preload()
})
onHide(() => {
if (isPlaying.value) {
stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
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;
$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);
/* ============================================================
* 页面容器
* ============================================================ */
.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;
}
&.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;
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 {
/* 1:1 矩形,跟 video 内容比例一致,消除左右黑边
background 设成 video 内容右上角的浅灰白色,
万一还有 1rpx 缝隙也不会出现刺眼黑边 */
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%;
/* 微信 native video 自身默认是 #000 黑底,父容器 bg 在 web 看不到,
只能让 video 占满 + 让父 bg 跟 video 内容色融合 */
background: #e8ecf2;
}
/* 覆盖在 video 右上角原生 PIP/小窗按钮上的色块
颜色取 video 内容右上角的浅灰白渐变(#dde2eb 左右),让色块融入视频画面
仅小程序端有效(cover-view 是小程序唯一能盖住 native 组件的元素) */
.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;
}
}
}
/* ============================================================
* 右下角微调(不显眼)
* ============================================================ */
.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;
font-size: 22rpx;
color: $text-3;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: all 0.15s;
&.active {
background: $brand;
color: #fff;
box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4);
}
}
.qa-divider {
width: 1rpx;
height: 22rpx;
background: rgba(15, 23, 42, 0.08);
}
</style>