Files
zyt/TUICallKit-Vue3/pages/training/metronome.vue
T
longandCursor fbf3d47b2a fix(metronome): 跑步视频改用腾讯云 COS CDN 地址
微信开发者工具加载代码包内 mp4 极不稳定,改用 CDN 直接 URL:
- 抽取 RUNNER_VIDEO_URL 常量,后续替换源只改一处
- 注释里标记需把 cos.ap-guangzhou.myqcloud.com 加到
  下载合法域名白名单(正式发布前)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:41:28 +08:00

609 lines
15 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-card">
<view class="walker-header">
<view class="badge" :class="badgeClass">
<view class="badge-dot" />
<text>{{ walkerTip }}</text>
</view>
<text class="status">{{ statusHint }}</text>
</view>
<view class="walker" :class="{ playing: isPlaying }">
<view class="runner-card">
<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"
object-fit="contain"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
/>
</view>
</view>
</view>
<!-- ===== 控制卡片:BPM + 拍号 ===== -->
<view class="control-card">
<view class="bpm-row">
<view class="step-btn" @click="adjustBpm(-10)">
<text class="step-num">10</text>
</view>
<view class="step-btn" @click="adjustBpm(-1)">
<text class="step-num">1</text>
</view>
<view class="step-btn" @click="adjustBpm(1)">
<text class="step-num">+1</text>
</view>
<view class="step-btn" @click="adjustBpm(10)">
<text class="step-num">+10</text>
</view>
</view>
<view class="divider" />
<view class="meter-row">
<text class="meter-label">每组拍数</text>
<view class="meter-options">
<view
v-for="m in METER_OPTIONS"
:key="m.value"
class="meter-btn"
:class="{ active: accentEvery === m.value }"
@click="onMeterChange(m.value)"
>
<text>{{ m.label }}</text>
</view>
</view>
</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 基准
* 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 },
{ label: '4', value: 4 },
]
const metronome = useMetronome({
initialBpm: 80,
accentEvery: 4,
clickSrc: '/static/training/audio/click.mp3',
accentSrc: '/static/training/audio/click-wood.mp3',
})
const {
bpm,
isPlaying,
beatIndex,
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 同步 */
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)
})
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)
/* 元数据加载完后再设 playbackRate,部分基础库初始化时设非 1.0 会卡 */
try {
videoCtx?.playbackRate?.(playbackRate.value)
} catch (_) {}
}
/* 节拍器状态变化 → 同步视频播放/暂停 */
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`,
}))
const statusHint = computed(() => {
if (isPlaying.value) {
const cur = ((beatIndex.value - 1) % accentEvery.value) + 1
return `第 ${cur} 拍 / 共 ${accentEvery.value} 拍`
}
return '轻点中央圆点开始'
})
const walkerTip = computed(() => {
if (bpm.value < 70) return '慢走 · 热身'
if (bpm.value < 100) return '健走 · 日常'
if (bpm.value < 130) return '快走 · 提速'
if (bpm.value < 170) return '慢跑 · 有氧'
return '快跑 · 高强'
})
const badgeClass = computed(() => {
if (bpm.value < 100) return 'badge-mild'
if (bpm.value < 130) return 'badge-mid'
if (bpm.value < 170) return 'badge-strong'
return 'badge-hot'
})
onShow(() => {
preload()
})
onHide(() => {
if (isPlaying.value) {
stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
<style lang="scss" scoped>
/* ============================================================
* 设计 token
* ============================================================ */
$bg-grad: linear-gradient(180deg, #0a0f1c 0%, #141a2a 100%);
$card-bg: rgba(255, 255, 255, 0.035);
$card-border: rgba(255, 255, 255, 0.06);
$text-1: #f1f5f9;
$text-2: #cbd5e1;
$text-3: #64748b;
$brand: #22c55e;
$brand-deep: #15803d;
$accent: #f59e0b;
$accent-deep: #b45309;
$radius-card: 28rpx;
$radius-btn: 20rpx;
/* ============================================================
* 页面容器
* ============================================================ */
.page {
height: 100vh;
box-sizing: border-box;
padding: 30rpx 28rpx 60rpx;
background: $bg-grad;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
color: $text-1;
overflow: hidden;
}
/* ============================================================
* 节拍器主舞台
* ============================================================ */
.stage {
position: relative;
width: 540rpx;
height: 540rpx;
margin-top: 20rpx;
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(34, 197, 94, 0.14);
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(34, 197, 94, 0.45);
}
100% {
transform: scale(1.18);
opacity: 0;
border-color: rgba(34, 197, 94, 0);
}
}
.core {
position: relative;
width: 420rpx;
height: 420rpx;
border-radius: 50%;
background: linear-gradient(140deg, $brand 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.22),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.1),
0 20rpx 50rpx rgba(34, 197, 94, 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%, $accent-deep 100%);
box-shadow:
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.22),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
0 20rpx 50rpx rgba(245, 158, 11, 0.4);
}
.bpm-num {
font-size: 180rpx;
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.18);
}
.bpm-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.65);
letter-spacing: 6rpx;
margin-top: 10rpx;
font-weight: 600;
}
.ctrl {
margin-top: 26rpx;
height: 76rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
@keyframes core-beat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
}
/* 播放/暂停 icon (CSS 几何) */
.icon-play {
width: 0;
height: 0;
border-left: 60rpx solid #fff;
border-top: 38rpx solid transparent;
border-bottom: 38rpx solid transparent;
margin-left: 14rpx;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.2));
}
.icon-pause {
display: flex;
gap: 18rpx;
height: 70rpx;
.bar {
width: 18rpx;
height: 100%;
background: #fff;
border-radius: 5rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.2);
}
}
/* ============================================================
* 走路动画卡片
* ============================================================ */
.walker-card {
width: 100%;
background: $card-bg;
border: 1rpx solid $card-border;
border-radius: $radius-card;
padding: 24rpx 28rpx 32rpx;
backdrop-filter: blur(20rpx);
}
.walker-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.badge {
display: flex;
align-items: center;
gap: 10rpx;
padding: 8rpx 20rpx;
background: rgba(34, 197, 94, 0.12);
border: 1rpx solid rgba(34, 197, 94, 0.3);
border-radius: 999rpx;
font-size: 24rpx;
color: $brand;
font-weight: 600;
.badge-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8rpx currentColor;
}
&.badge-mild {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
&.badge-mid {
background: rgba(251, 191, 36, 0.12);
border-color: rgba(251, 191, 36, 0.3);
color: #fbbf24;
}
&.badge-strong {
background: rgba(249, 115, 22, 0.12);
border-color: rgba(249, 115, 22, 0.3);
color: #fb923c;
}
&.badge-hot {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.32);
color: #f87171;
}
}
.status {
font-size: 24rpx;
color: $text-3;
letter-spacing: 1rpx;
}
.walker {
position: relative;
width: 100%;
height: 240rpx;
display: flex;
justify-content: center;
align-items: flex-end;
}
/* ============================================================
* 跑步小人视频(白卡嵌入)
* ============================================================ */
.runner-card {
position: absolute;
bottom: 18rpx;
left: 50%;
transform: translateX(-50%);
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-video {
width: 100%;
height: 100%;
background: #fff;
}
/* ============================================================
* 控制卡片(BPM + 拍号)
* ============================================================ */
.control-card {
width: 100%;
background: $card-bg;
border: 1rpx solid $card-border;
border-radius: $radius-card;
padding: 28rpx;
backdrop-filter: blur(20rpx);
}
.bpm-row {
display: flex;
gap: 14rpx;
}
.step-btn {
flex: 1;
height: 96rpx;
border-radius: $radius-btn;
background: rgba(255, 255, 255, 0.05);
border: 1rpx solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
&:active {
background: rgba(34, 197, 94, 0.18);
border-color: rgba(34, 197, 94, 0.5);
transform: scale(0.96);
}
.step-num {
font-size: 38rpx;
color: $text-1;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
}
}
.divider {
height: 1rpx;
background: rgba(255, 255, 255, 0.06);
margin: 24rpx 0;
}
.meter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.meter-label {
font-size: 26rpx;
color: $text-3;
letter-spacing: 1rpx;
}
.meter-options {
display: flex;
gap: 10rpx;
background: rgba(255, 255, 255, 0.04);
padding: 6rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(255, 255, 255, 0.05);
}
.meter-btn {
min-width: 84rpx;
height: 60rpx;
padding: 0 20rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.18s;
text {
font-size: 30rpx;
color: $text-2;
font-weight: 600;
}
&.active {
background: $brand;
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.4);
text {
color: #fff;
}
}
}
</style>