552 lines
14 KiB
Vue
552 lines
14 KiB
Vue
<template>
|
||
<view class="page" :style="rootStyle">
|
||
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
|
||
<view class="stage-canvas-wrapper">
|
||
<WalkerCanvas
|
||
ref="walkerCanvasRef"
|
||
:bpm="currentBpm"
|
||
:is-playing="isPlaying"
|
||
@toggle-play="onCenterTap"
|
||
/>
|
||
</view>
|
||
|
||
<!-- ===== 音色选择 ===== -->
|
||
<view class="sound-selector">
|
||
<view
|
||
class="sound-option"
|
||
:class="{ active: currentSound === 'crisp' }"
|
||
@click="onSoundSelect('crisp')"
|
||
>
|
||
<text class="sound-icon">✨</text>
|
||
<text class="sound-label">清脆</text>
|
||
</view>
|
||
<view
|
||
class="sound-option"
|
||
:class="{ active: currentSound === 'wood' }"
|
||
@click="onSoundSelect('wood')"
|
||
>
|
||
<text class="sound-icon">🪵</text>
|
||
<text class="sound-label">木鱼</text>
|
||
</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-name">{{ p.label }}</text>
|
||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||
<text class="preset-desc">{{ p.desc }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
|
||
<view class="custom-section">
|
||
<!-- 标题栏(始终显示) -->
|
||
<view class="custom-header" @click="onToggleCustom">
|
||
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
|
||
</view>
|
||
|
||
<!-- 折叠内容(仅展开时显示) -->
|
||
<view v-if="customExpanded" class="custom-content">
|
||
<!-- 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">{{ currentBpm }}</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>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||
import { useMetronome } from '../hooks/useMetronome'
|
||
import WalkerCanvas from './components/walker-canvas.vue'
|
||
|
||
type LoopId = 'slow' | 'normal' | 'brisk'
|
||
interface LoopPreset {
|
||
id: LoopId
|
||
bpm: number
|
||
label: string
|
||
desc: string
|
||
}
|
||
|
||
const LOOP_PRESETS: readonly LoopPreset[] = [
|
||
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
|
||
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
|
||
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
|
||
]
|
||
|
||
const customExpanded = ref<boolean>(false)
|
||
const currentLoop = ref<LoopId | null>('normal')
|
||
const currentSound = ref<'crisp' | 'wood'>('crisp')
|
||
const walkerCanvasRef = ref<any>(null)
|
||
|
||
/* 节拍器音效配置 */
|
||
const SOUND_PRESETS = {
|
||
crisp: {
|
||
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
|
||
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
|
||
},
|
||
wood: {
|
||
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||
},
|
||
}
|
||
|
||
// 统一采用高精度引擎,彻底抛弃卡顿的后台播放引擎
|
||
const fg = useMetronome({
|
||
initialBpm: 130,
|
||
accentEvery: 2,
|
||
clickSrc: SOUND_PRESETS.crisp.normal,
|
||
accentSrc: SOUND_PRESETS.crisp.accent,
|
||
poolSize: 4,
|
||
// 【完美音画同步核心】
|
||
onBeat: (index, isAccent) => {
|
||
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
|
||
walkerCanvasRef.value.triggerBeat()
|
||
}
|
||
}
|
||
})
|
||
|
||
const isPlaying = computed(() => fg.isPlaying.value)
|
||
const currentBpm = computed(() => fg.bpm.value)
|
||
const customAccent = computed(() => fg.accentEvery.value)
|
||
const intervalMs = computed(() => 60000 / fg.bpm.value)
|
||
|
||
/* ============================================================
|
||
* 中央圆按钮
|
||
* ============================================================ */
|
||
const onCenterTap = () => {
|
||
if (fg.isPlaying.value) {
|
||
fg.stop()
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
} else {
|
||
fg.start()
|
||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 档位卡片
|
||
* ============================================================ */
|
||
const onPresetTap = (id: LoopId) => {
|
||
const preset = LOOP_PRESETS.find(p => p.id === id)
|
||
if (!preset) return
|
||
|
||
currentLoop.value = id
|
||
fg.setBpm(preset.bpm)
|
||
if (!fg.isPlaying.value) {
|
||
fg.start()
|
||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 自定义折叠区切换
|
||
* ============================================================ */
|
||
const onToggleCustom = () => {
|
||
customExpanded.value = !customExpanded.value
|
||
if (customExpanded.value) {
|
||
fg.preload()
|
||
} else {
|
||
// 收起时,如果当前 BPM 刚好匹配某个档位,高亮对应档位卡片
|
||
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
|
||
currentLoop.value = matched ? matched.id : null
|
||
}
|
||
}
|
||
|
||
const onBpmDelta = (delta: number) => {
|
||
fg.setBpm(fg.bpm.value + delta)
|
||
currentLoop.value = null // 用户手动微调后取消档位高亮
|
||
}
|
||
|
||
const onAccentSelect = (n: number) => {
|
||
fg.setAccentEvery(n)
|
||
}
|
||
|
||
const onSoundSelect = (sound: 'crisp' | 'wood') => {
|
||
currentSound.value = sound
|
||
const preset = SOUND_PRESETS[sound]
|
||
fg.updateAudioSrc(preset.normal, preset.accent)
|
||
}
|
||
|
||
const rootStyle = computed(() => ({
|
||
'--beat-duration': `${intervalMs.value}ms`,
|
||
}))
|
||
|
||
onShow(() => {
|
||
fg.preload()
|
||
})
|
||
|
||
function cleanupMetronome() {
|
||
fg.stop()
|
||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||
}
|
||
|
||
onHide(cleanupMetronome)
|
||
onUnload(cleanupMetronome)
|
||
|
||
onUnmounted(cleanupMetronome)
|
||
</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;
|
||
$warn: #f59e0b;
|
||
$warn-soft: #fef3c7;
|
||
$radius-card: 24rpx;
|
||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||
|
||
/* ============================================================
|
||
* 页面容器
|
||
* ============================================================ */
|
||
.page {
|
||
position: relative;
|
||
min-height: 100vh;
|
||
box-sizing: border-box;
|
||
padding: 30rpx 28rpx 50rpx;
|
||
background: $bg-color;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
color: $text-1;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 统一运动律动视区 (Canvas)
|
||
* ============================================================ */
|
||
.stage-canvas-wrapper {
|
||
width: 100vw;
|
||
margin-left: -28rpx;
|
||
margin-right: -28rpx;
|
||
flex-shrink: 0; /* 防止被压缩 */
|
||
height: 600rpx; /* 固定高度,不随内容变化 */
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
margin-bottom: 24rpx;
|
||
position: relative;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 音色选择器
|
||
* ============================================================ */
|
||
.sound-selector {
|
||
width: 100%;
|
||
display: flex;
|
||
gap: 14rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.sound-option {
|
||
flex: 1;
|
||
background: $card-bg;
|
||
border: 2rpx solid $card-border;
|
||
border-radius: $radius-card;
|
||
padding: 20rpx 16rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10rpx;
|
||
box-shadow: $shadow-sm;
|
||
transition: all 0.18s;
|
||
|
||
.sound-icon {
|
||
font-size: 32rpx;
|
||
}
|
||
|
||
.sound-label {
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
color: $text-2;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
&.active {
|
||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||
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);
|
||
|
||
.sound-label {
|
||
color: $brand-deep;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 三档位推荐
|
||
* ============================================================ */
|
||
.presets {
|
||
width: 100%;
|
||
display: flex;
|
||
gap: 14rpx;
|
||
}
|
||
|
||
.preset {
|
||
flex: 1;
|
||
background: $card-bg;
|
||
border: 2rpx solid $card-border;
|
||
border-radius: $radius-card;
|
||
padding: 26rpx 8rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
box-shadow: $shadow-sm;
|
||
transition: all 0.18s;
|
||
|
||
.preset-name {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: $text-1;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
.preset-bpm {
|
||
font-size: 22rpx;
|
||
color: $text-2;
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.preset-desc {
|
||
font-size: 18rpx;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 自定义节奏区域(标题栏 + 折叠内容)
|
||
* ============================================================ */
|
||
.custom-section {
|
||
width: 100%;
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
.custom-header {
|
||
width: 100%;
|
||
padding: 16rpx 6rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.custom-title {
|
||
font-size: 22rpx;
|
||
color: $text-3;
|
||
letter-spacing: 0.5rpx;
|
||
|
||
&:active {
|
||
color: $text-2;
|
||
}
|
||
}
|
||
}
|
||
|
||
.custom-content {
|
||
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;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.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>
|