feat(uniapp): 节拍器独立工具页面
- 新增 /pages/training/metronome 节拍器页面: · 中心大脉冲圆 + 重音变色(绿→金) · 拍号选择 1/4 ~ 6/8,第 1 拍重音视觉指示 · BPM 滑块 (40-240) + 一键 ±1/±5 步进 · Tap Tempo(连续 2-5 次点击自动测速) · 速度术语提示(Largo / Andante / Allegro 等) · 屏幕常亮、切后台自动暂停 - useMetronome 新增 setAccentEvery / tapTempo 方法,支持 accentSrc 重音音色 - 训练页右上角加节拍器入口 - DEV 悬浮按钮升级为可展开菜单(练一练 + 节拍器) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<view v-if="visible" class="dev-training-entry" @click="goTraining">
|
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||||
<view class="entry-icon">💪</view>
|
<view v-if="expanded" class="menu-list" @click.stop>
|
||||||
<view class="entry-text">练一练</view>
|
<view class="menu-item" @click="goto('/pages/training/index')">
|
||||||
<view class="entry-tag">DEV</view>
|
<view class="menu-icon">💪</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">练一练</text>
|
||||||
|
<text class="menu-sub">器械跟练</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="menu-item" @click="goto('/pages/training/metronome')">
|
||||||
|
<view class="menu-icon metronome-icon">🎵</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">节拍器</text>
|
||||||
|
<text class="menu-sub">独立工具</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="entry-fab" :class="{ expanded }" @click="toggle">
|
||||||
|
<text class="fab-icon">{{ expanded ? '✕' : '💪' }}</text>
|
||||||
|
<text v-if="!expanded" class="fab-text">练</text>
|
||||||
|
<view v-if="!expanded" class="fab-tag">DEV</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -18,49 +37,64 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const visible = ref(isDevMode())
|
const visible = ref(isDevMode())
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
const goTraining = () => {
|
const toggle = () => {
|
||||||
uni.navigateTo({ url: '/pages/training/index' })
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const goto = (url: string) => {
|
||||||
|
expanded.value = false
|
||||||
|
uni.navigateTo({ url })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.dev-training-entry {
|
.dev-entry-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 24rpx;
|
right: 24rpx;
|
||||||
bottom: v-bind('props.bottom + "rpx"');
|
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-fab {
|
||||||
width: 110rpx;
|
width: 110rpx;
|
||||||
height: 110rpx;
|
height: 110rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
box-shadow: 0 6rpx 20rpx rgba(34, 197, 94, 0.4);
|
box-shadow: 0 6rpx 20rpx rgba(34, 197, 94, 0.4);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s, background 0.2s;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-icon {
|
&.expanded {
|
||||||
font-size: 40rpx;
|
background: linear-gradient(135deg, #64748b, #334155);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-text {
|
.fab-text {
|
||||||
font-size: 18rpx;
|
font-size: 18rpx;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-tag {
|
.fab-tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8rpx;
|
top: -8rpx;
|
||||||
right: -8rpx;
|
right: -8rpx;
|
||||||
@@ -72,4 +106,67 @@ const goTraining = () => {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
animation: slide-up 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||||
|
min-width: 240rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
&.metronome-icon {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.menu-sub {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface MetronomeOptions {
|
|||||||
bpmMin?: number
|
bpmMin?: number
|
||||||
bpmMax?: number
|
bpmMax?: number
|
||||||
clickSrc?: string
|
clickSrc?: string
|
||||||
|
accentSrc?: string
|
||||||
accentEvery?: number
|
accentEvery?: number
|
||||||
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -13,9 +14,9 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
const {
|
const {
|
||||||
initialBpm = 80,
|
initialBpm = 80,
|
||||||
bpmMin = 40,
|
bpmMin = 40,
|
||||||
bpmMax = 200,
|
bpmMax = 240,
|
||||||
clickSrc = '/static/training/audio/click.mp3',
|
clickSrc = '/static/training/audio/click.mp3',
|
||||||
accentEvery = 4,
|
accentSrc,
|
||||||
onBeat,
|
onBeat,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -23,37 +24,47 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
const isPlaying = ref<boolean>(false)
|
const isPlaying = ref<boolean>(false)
|
||||||
const beatIndex = ref<number>(0)
|
const beatIndex = ref<number>(0)
|
||||||
const isAccent = ref<boolean>(false)
|
const isAccent = ref<boolean>(false)
|
||||||
|
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||||
|
|
||||||
const intervalMs = computed(() => 60000 / bpm.value)
|
const intervalMs = computed(() => 60000 / bpm.value)
|
||||||
|
|
||||||
let audioCtx: UniApp.InnerAudioContext | null = null
|
let clickCtx: UniApp.InnerAudioContext | null = null
|
||||||
|
let accentCtx: UniApp.InnerAudioContext | null = null
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
let nextTickAt = 0
|
let nextTickAt = 0
|
||||||
|
|
||||||
const ensureAudio = () => {
|
const ensureAudio = () => {
|
||||||
if (audioCtx) return
|
if (!clickCtx) {
|
||||||
audioCtx = uni.createInnerAudioContext()
|
clickCtx = uni.createInnerAudioContext()
|
||||||
audioCtx.src = clickSrc
|
clickCtx.src = clickSrc
|
||||||
audioCtx.obeyMuteSwitch = false
|
clickCtx.obeyMuteSwitch = false
|
||||||
|
}
|
||||||
|
if (accentSrc && !accentCtx) {
|
||||||
|
accentCtx = uni.createInnerAudioContext()
|
||||||
|
accentCtx.src = accentSrc
|
||||||
|
accentCtx.obeyMuteSwitch = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const playClick = () => {
|
const playSound = (accent: boolean) => {
|
||||||
if (!audioCtx) return
|
const ctx = accent && accentCtx ? accentCtx : clickCtx
|
||||||
|
if (!ctx) return
|
||||||
try {
|
try {
|
||||||
audioCtx.seek(0)
|
ctx.seek(0)
|
||||||
audioCtx.play()
|
ctx.play()
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
audioCtx.play()
|
ctx.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!isPlaying.value) return
|
if (!isPlaying.value) return
|
||||||
|
|
||||||
playClick()
|
const every = Math.max(1, accentEveryRef.value)
|
||||||
|
const accent = beatIndex.value % every === 0
|
||||||
const accent = beatIndex.value % accentEvery === 0
|
|
||||||
isAccent.value = accent
|
isAccent.value = accent
|
||||||
|
|
||||||
|
playSound(accent)
|
||||||
onBeat?.(beatIndex.value, accent)
|
onBeat?.(beatIndex.value, accent)
|
||||||
beatIndex.value++
|
beatIndex.value++
|
||||||
|
|
||||||
@@ -85,10 +96,37 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setAccentEvery = (n: number) => {
|
||||||
|
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||||
|
beatIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let tapTimes: number[] = []
|
||||||
|
const tapTempo = (): number | null => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (tapTimes.length > 0 && now - tapTimes[tapTimes.length - 1] > 2000) {
|
||||||
|
tapTimes = []
|
||||||
|
}
|
||||||
|
tapTimes.push(now)
|
||||||
|
if (tapTimes.length > 5) tapTimes.shift()
|
||||||
|
if (tapTimes.length < 2) return null
|
||||||
|
|
||||||
|
const intervals: number[] = []
|
||||||
|
for (let i = 1; i < tapTimes.length; i++) {
|
||||||
|
intervals.push(tapTimes[i] - tapTimes[i - 1])
|
||||||
|
}
|
||||||
|
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length
|
||||||
|
const detected = Math.round(60000 / avg)
|
||||||
|
setBpm(detected)
|
||||||
|
return detected
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stop()
|
stop()
|
||||||
audioCtx?.destroy?.()
|
clickCtx?.destroy?.()
|
||||||
audioCtx = null
|
accentCtx?.destroy?.()
|
||||||
|
clickCtx = null
|
||||||
|
accentCtx = null
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -97,8 +135,11 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
beatIndex,
|
beatIndex,
|
||||||
isAccent,
|
isAccent,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
|
accentEvery: accentEveryRef,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
setBpm,
|
setBpm,
|
||||||
|
setAccentEvery,
|
||||||
|
tapTempo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,14 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "练一练"
|
"navigationBarTitleText": "练一练"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/training/metronome",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "节拍器",
|
||||||
|
"navigationBarBackgroundColor": "#0f172a",
|
||||||
|
"navigationBarTextStyle": "white"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [{
|
"subPackages": [{
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="training-page">
|
<view class="training-page">
|
||||||
<view class="header">
|
<view class="header">
|
||||||
<text class="title">练一练</text>
|
<view class="header-main">
|
||||||
<text class="subtitle">器械动作跟练</text>
|
<text class="title">练一练</text>
|
||||||
|
<text class="subtitle">器械动作跟练</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-link" @click="goMetronome">
|
||||||
|
🎵 节拍器
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="equipment-tabs">
|
<view class="equipment-tabs">
|
||||||
@@ -280,6 +285,10 @@ const onSkipRest = () => {
|
|||||||
session.skipRest()
|
session.skipRest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goMetronome = () => {
|
||||||
|
uni.navigateTo({ url: '/pages/training/metronome' })
|
||||||
|
}
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
if (isTraining.value || isResting.value) {
|
if (isTraining.value || isResting.value) {
|
||||||
onStop()
|
onStop()
|
||||||
@@ -300,10 +309,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-start;
|
justify-content: space-between;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 44rpx;
|
font-size: 44rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -314,6 +329,18 @@ onUnmounted(() => {
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-top: 6rpx;
|
margin-top: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-link {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.equipment-tabs {
|
.equipment-tabs {
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
<template>
|
||||||
|
<view class="metronome-page">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">节拍器</text>
|
||||||
|
<text class="subtitle">METRONOME</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="beat-bar">
|
||||||
|
<view
|
||||||
|
v-for="i in accentEvery"
|
||||||
|
:key="i"
|
||||||
|
class="beat-dot"
|
||||||
|
:class="{
|
||||||
|
accent: i === 1,
|
||||||
|
active: ((beatIndex - 1) % accentEvery) + 1 === i && isPlaying,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="pulse-stage">
|
||||||
|
<view
|
||||||
|
class="pulse-ring"
|
||||||
|
:class="{ playing: isPlaying }"
|
||||||
|
:style="ringStyle"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
class="pulse-core"
|
||||||
|
:class="{ playing: isPlaying, accent: isAccent }"
|
||||||
|
:style="coreStyle"
|
||||||
|
>
|
||||||
|
<text class="bpm-num">{{ bpm }}</text>
|
||||||
|
<text class="bpm-label">BPM</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bpm-control">
|
||||||
|
<view class="bpm-stepper">
|
||||||
|
<view class="step-btn" @click="adjustBpm(-5)">-5</view>
|
||||||
|
<view class="step-btn small" @click="adjustBpm(-1)">-1</view>
|
||||||
|
<view class="step-btn small" @click="adjustBpm(1)">+1</view>
|
||||||
|
<view class="step-btn" @click="adjustBpm(5)">+5</view>
|
||||||
|
</view>
|
||||||
|
<slider
|
||||||
|
:value="bpm"
|
||||||
|
:min="40"
|
||||||
|
:max="240"
|
||||||
|
:step="1"
|
||||||
|
block-size="28"
|
||||||
|
activeColor="#22c55e"
|
||||||
|
backgroundColor="#e5e7eb"
|
||||||
|
@change="onSliderChange"
|
||||||
|
/>
|
||||||
|
<view class="bpm-range">
|
||||||
|
<text>40</text>
|
||||||
|
<text>240</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="meter-section">
|
||||||
|
<text class="section-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)"
|
||||||
|
>
|
||||||
|
{{ m.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tempo-mark-section" v-if="tempoMark">
|
||||||
|
<text class="tempo-mark">{{ tempoMark }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="actions">
|
||||||
|
<view class="tap-btn" @click="onTap">
|
||||||
|
<text class="tap-icon">👆</text>
|
||||||
|
<text class="tap-label">Tap Tempo</text>
|
||||||
|
<text v-if="tapHint" class="tap-hint">{{ tapHint }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="play-btn"
|
||||||
|
:class="{ playing: isPlaying }"
|
||||||
|
@click="togglePlay"
|
||||||
|
>
|
||||||
|
<text class="play-icon">{{ isPlaying ? '■' : '▶' }}</text>
|
||||||
|
<text class="play-label">{{ isPlaying ? '停止' : '开始' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="hint">
|
||||||
|
<text>{{ statusHint }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { onHide } from '@dcloudio/uni-app'
|
||||||
|
import { useMetronome } from '@/hooks/useMetronome'
|
||||||
|
|
||||||
|
const METER_OPTIONS = [
|
||||||
|
{ label: '1/4', value: 1 },
|
||||||
|
{ label: '2/4', value: 2 },
|
||||||
|
{ label: '3/4', value: 3 },
|
||||||
|
{ label: '4/4', value: 4 },
|
||||||
|
{ label: '6/8', value: 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const metronome = useMetronome({
|
||||||
|
initialBpm: 80,
|
||||||
|
accentEvery: 4,
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
bpm,
|
||||||
|
isPlaying,
|
||||||
|
beatIndex,
|
||||||
|
isAccent,
|
||||||
|
accentEvery,
|
||||||
|
intervalMs,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
setBpm,
|
||||||
|
setAccentEvery,
|
||||||
|
tapTempo,
|
||||||
|
} = metronome
|
||||||
|
|
||||||
|
const adjustBpm = (delta: number) => {
|
||||||
|
setBpm(bpm.value + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSliderChange = (e: any) => {
|
||||||
|
const v = e.detail?.value ?? e
|
||||||
|
setBpm(Number(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMeterChange = (val: number) => {
|
||||||
|
setAccentEvery(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tapHint = ref<string>('')
|
||||||
|
let tapHintTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const onTap = () => {
|
||||||
|
const detected = tapTempo()
|
||||||
|
if (detected !== null) {
|
||||||
|
tapHint.value = `已设为 ${detected}`
|
||||||
|
} else {
|
||||||
|
tapHint.value = '继续点击...'
|
||||||
|
}
|
||||||
|
if (tapHintTimer) clearTimeout(tapHintTimer)
|
||||||
|
tapHintTimer = setTimeout(() => {
|
||||||
|
tapHint.value = ''
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringStyle = computed(() => ({
|
||||||
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const coreStyle = computed(() => ({
|
||||||
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tempoMark = computed(() => {
|
||||||
|
const b = bpm.value
|
||||||
|
if (b < 60) return 'Largo · 极慢'
|
||||||
|
if (b < 76) return 'Adagio · 慢板'
|
||||||
|
if (b < 108) return 'Andante · 行板'
|
||||||
|
if (b < 120) return 'Moderato · 中板'
|
||||||
|
if (b < 156) return 'Allegro · 快板'
|
||||||
|
if (b < 200) return 'Vivace · 活泼'
|
||||||
|
return 'Presto · 急板'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusHint = computed(() => {
|
||||||
|
if (isPlaying.value) return `第 ${((beatIndex.value - 1) % accentEvery.value) + 1} 拍 / 共 ${accentEvery.value} 拍`
|
||||||
|
return '点击播放按钮开始'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(intervalMs, () => {})
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (tapHintTimer) clearTimeout(tapHintTimer)
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.metronome-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32rpx 32rpx 80rpx;
|
||||||
|
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #64748b;
|
||||||
|
letter-spacing: 8rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 拍点指示 ===== */
|
||||||
|
.beat-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.beat-dot {
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #334155;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&.accent {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
transform: scale(1.6);
|
||||||
|
box-shadow: 0 0 16rpx #22c55e;
|
||||||
|
|
||||||
|
&.accent {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 20rpx #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 中心脉冲圆 ===== */
|
||||||
|
.pulse-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 460rpx;
|
||||||
|
height: 460rpx;
|
||||||
|
margin-bottom: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid rgba(34, 197, 94, 0.2);
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes ring-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(34, 197, 94, 0.6);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.15);
|
||||||
|
opacity: 0;
|
||||||
|
border-color: rgba(34, 197, 94, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-core {
|
||||||
|
position: relative;
|
||||||
|
width: 360rpx;
|
||||||
|
height: 360rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 12rpx 40rpx rgba(34, 197, 94, 0.35);
|
||||||
|
transition: transform 0.1s, background 0.15s;
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing.accent {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
box-shadow: 0 12rpx 40rpx rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-num {
|
||||||
|
font-size: 140rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes core-beat {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BPM 控制 ===== */
|
||||||
|
.bpm-control {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 50rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-stepper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
min-width: 88rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #f1f5f9;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
min-width: 72rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-range {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
slider {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 拍号 ===== */
|
||||||
|
.meter-section {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.meter-btn {
|
||||||
|
min-width: 100rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 速度术语 ===== */
|
||||||
|
.tempo-mark-section {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
.tempo-mark {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 操作区 ===== */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tap-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 120rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tap-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.tap-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
.tap-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
flex: 2;
|
||||||
|
height: 120rpx;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.3);
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
font-size: 44rpx;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.play-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 状态提示 ===== */
|
||||||
|
.hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user