feat(training/metronome): 修复冷启动延迟 + 老人友好 UI
- useMetronome 改用音频实例池(默认 4 个 InnerAudioContext 轮流播放), 并在创建时静音预热,解决重新开始第一拍延迟 100-300ms 的问题; 同时也避免快 BPM 时上一拍未播完导致的丢音 - 暴露 preload(),节拍器页面 onShow 时主动预加载 - 移除 useMetronome 的 tapTempo 接口 - 重写节拍器页面: · 中央大圆即"开始/暂停"按钮(老人一眼就懂的交互) · 移除 Tap Tempo、滑块、速度术语 · 仅保留 -10/-1/+1/+10 大步进按钮调速 · 拍号简化为 2/3/4 拍三选项 · 大字号 BPM(200rpx) + 高对比度配色 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,9 +7,86 @@ export interface MetronomeOptions {
|
|||||||
clickSrc?: string
|
clickSrc?: string
|
||||||
accentSrc?: string
|
accentSrc?: string
|
||||||
accentEvery?: number
|
accentEvery?: number
|
||||||
|
poolSize?: number
|
||||||
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||||
|
* 解决两个问题:
|
||||||
|
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||||
|
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||||
|
*/
|
||||||
|
class AudioPool {
|
||||||
|
private list: UniApp.InnerAudioContext[] = []
|
||||||
|
private cursor = 0
|
||||||
|
private warmedUp = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private src: string,
|
||||||
|
private size: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private create() {
|
||||||
|
for (let i = 0; i < this.size; i++) {
|
||||||
|
const ctx = uni.createInnerAudioContext()
|
||||||
|
ctx.src = this.src
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.autoplay = false
|
||||||
|
this.list.push(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热:静音播放一次每个实例,让音频解码完成
|
||||||
|
* 这样后续 play() 时就是热启动,没有冷启动延迟
|
||||||
|
*/
|
||||||
|
warmUp() {
|
||||||
|
if (this.warmedUp) return
|
||||||
|
if (this.list.length === 0) this.create()
|
||||||
|
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
const originalVolume = ctx.volume
|
||||||
|
ctx.volume = 0
|
||||||
|
try {
|
||||||
|
ctx.play()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ctx.stop()
|
||||||
|
ctx.volume = originalVolume === 0 ? 1 : originalVolume
|
||||||
|
} catch (_) {}
|
||||||
|
}, 80)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.warmedUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (this.list.length === 0) {
|
||||||
|
this.create()
|
||||||
|
this.warmUp()
|
||||||
|
}
|
||||||
|
const ctx = this.list[this.cursor]
|
||||||
|
this.cursor = (this.cursor + 1) % this.list.length
|
||||||
|
try {
|
||||||
|
ctx.seek(0)
|
||||||
|
ctx.play()
|
||||||
|
} catch (_) {
|
||||||
|
ctx.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.destroy?.()
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.list = []
|
||||||
|
this.warmedUp = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useMetronome(options: MetronomeOptions = {}) {
|
export function useMetronome(options: MetronomeOptions = {}) {
|
||||||
const {
|
const {
|
||||||
initialBpm = 80,
|
initialBpm = 80,
|
||||||
@@ -17,6 +94,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
bpmMax = 240,
|
bpmMax = 240,
|
||||||
clickSrc = '/static/training/audio/click.mp3',
|
clickSrc = '/static/training/audio/click.mp3',
|
||||||
accentSrc,
|
accentSrc,
|
||||||
|
poolSize = 4,
|
||||||
onBeat,
|
onBeat,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -28,32 +106,27 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
|
|
||||||
const intervalMs = computed(() => 60000 / bpm.value)
|
const intervalMs = computed(() => 60000 / bpm.value)
|
||||||
|
|
||||||
let clickCtx: UniApp.InnerAudioContext | null = null
|
let clickPool: AudioPool | null = null
|
||||||
let accentCtx: UniApp.InnerAudioContext | null = null
|
let accentPool: AudioPool | 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 (!clickCtx) {
|
if (!clickPool) {
|
||||||
clickCtx = uni.createInnerAudioContext()
|
clickPool = new AudioPool(clickSrc, poolSize)
|
||||||
clickCtx.src = clickSrc
|
clickPool.warmUp()
|
||||||
clickCtx.obeyMuteSwitch = false
|
|
||||||
}
|
}
|
||||||
if (accentSrc && !accentCtx) {
|
if (accentSrc && !accentPool) {
|
||||||
accentCtx = uni.createInnerAudioContext()
|
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||||
accentCtx.src = accentSrc
|
accentPool.warmUp()
|
||||||
accentCtx.obeyMuteSwitch = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const playSound = (accent: boolean) => {
|
const playSound = (accent: boolean) => {
|
||||||
const ctx = accent && accentCtx ? accentCtx : clickCtx
|
if (accent && accentPool) {
|
||||||
if (!ctx) return
|
accentPool.play()
|
||||||
try {
|
} else if (clickPool) {
|
||||||
ctx.seek(0)
|
clickPool.play()
|
||||||
ctx.play()
|
|
||||||
} catch (_) {
|
|
||||||
ctx.play()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,32 +174,19 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
beatIndex.value = 0
|
beatIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let tapTimes: number[] = []
|
/**
|
||||||
const tapTempo = (): number | null => {
|
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
|
||||||
const now = Date.now()
|
*/
|
||||||
if (tapTimes.length > 0 && now - tapTimes[tapTimes.length - 1] > 2000) {
|
const preload = () => {
|
||||||
tapTimes = []
|
ensureAudio()
|
||||||
}
|
|
||||||
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()
|
||||||
clickCtx?.destroy?.()
|
clickPool?.destroy()
|
||||||
accentCtx?.destroy?.()
|
accentPool?.destroy()
|
||||||
clickCtx = null
|
clickPool = null
|
||||||
accentCtx = null
|
accentPool = null
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -140,6 +200,6 @@ export function useMetronome(options: MetronomeOptions = {}) {
|
|||||||
stop,
|
stop,
|
||||||
setBpm,
|
setBpm,
|
||||||
setAccentEvery,
|
setAccentEvery,
|
||||||
tapTempo,
|
preload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="metronome-page">
|
<view class="metronome-page">
|
||||||
<view class="header">
|
|
||||||
<text class="title">节拍器</text>
|
|
||||||
<text class="subtitle">METRONOME</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="beat-bar">
|
<view class="beat-bar">
|
||||||
<view
|
<view
|
||||||
v-for="i in accentEvery"
|
v-for="i in accentEvery"
|
||||||
@@ -17,7 +12,7 @@
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="pulse-stage">
|
<view class="pulse-stage" @click="togglePlay">
|
||||||
<view
|
<view
|
||||||
class="pulse-ring"
|
class="pulse-ring"
|
||||||
:class="{ playing: isPlaying }"
|
:class="{ playing: isPlaying }"
|
||||||
@@ -29,35 +24,31 @@
|
|||||||
:style="coreStyle"
|
:style="coreStyle"
|
||||||
>
|
>
|
||||||
<text class="bpm-num">{{ bpm }}</text>
|
<text class="bpm-num">{{ bpm }}</text>
|
||||||
<text class="bpm-label">BPM</text>
|
<text class="bpm-label">速度</text>
|
||||||
|
<view class="play-state">
|
||||||
|
<text class="play-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
|
||||||
|
<text class="play-text">{{ isPlaying ? '暂停' : '开始' }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="bpm-control">
|
<view class="bpm-control">
|
||||||
<view class="bpm-stepper">
|
<view class="step-btn big" @click="adjustBpm(-10)">
|
||||||
<view class="step-btn" @click="adjustBpm(-5)">-5</view>
|
<text class="step-num">-10</text>
|
||||||
<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>
|
</view>
|
||||||
<slider
|
<view class="step-btn small" @click="adjustBpm(-1)">
|
||||||
:value="bpm"
|
<text class="step-num">-1</text>
|
||||||
:min="40"
|
</view>
|
||||||
:max="240"
|
<view class="step-btn small" @click="adjustBpm(1)">
|
||||||
:step="1"
|
<text class="step-num">+1</text>
|
||||||
block-size="28"
|
</view>
|
||||||
activeColor="#22c55e"
|
<view class="step-btn big" @click="adjustBpm(10)">
|
||||||
backgroundColor="#e5e7eb"
|
<text class="step-num">+10</text>
|
||||||
@change="onSliderChange"
|
|
||||||
/>
|
|
||||||
<view class="bpm-range">
|
|
||||||
<text>40</text>
|
|
||||||
<text>240</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="meter-section">
|
<view class="meter-section">
|
||||||
<text class="section-label">拍号</text>
|
<text class="section-label">每组拍数</text>
|
||||||
<view class="meter-options">
|
<view class="meter-options">
|
||||||
<view
|
<view
|
||||||
v-for="m in METER_OPTIONS"
|
v-for="m in METER_OPTIONS"
|
||||||
@@ -66,32 +57,11 @@
|
|||||||
:class="{ active: accentEvery === m.value }"
|
:class="{ active: accentEvery === m.value }"
|
||||||
@click="onMeterChange(m.value)"
|
@click="onMeterChange(m.value)"
|
||||||
>
|
>
|
||||||
{{ m.label }}
|
<text class="meter-label">{{ m.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</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">
|
<view class="hint">
|
||||||
<text>{{ statusHint }}</text>
|
<text>{{ statusHint }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -99,16 +69,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted } from 'vue'
|
||||||
import { onHide } from '@dcloudio/uni-app'
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
import { useMetronome } from '@/hooks/useMetronome'
|
import { useMetronome } from '@/hooks/useMetronome'
|
||||||
|
|
||||||
const METER_OPTIONS = [
|
const METER_OPTIONS = [
|
||||||
{ label: '1/4', value: 1 },
|
{ label: '2 拍', value: 2 },
|
||||||
{ label: '2/4', value: 2 },
|
{ label: '3 拍', value: 3 },
|
||||||
{ label: '3/4', value: 3 },
|
{ label: '4 拍', value: 4 },
|
||||||
{ label: '4/4', value: 4 },
|
|
||||||
{ label: '6/8', value: 6 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const metronome = useMetronome({
|
const metronome = useMetronome({
|
||||||
@@ -128,18 +96,13 @@ const {
|
|||||||
stop,
|
stop,
|
||||||
setBpm,
|
setBpm,
|
||||||
setAccentEvery,
|
setAccentEvery,
|
||||||
tapTempo,
|
preload,
|
||||||
} = metronome
|
} = metronome
|
||||||
|
|
||||||
const adjustBpm = (delta: number) => {
|
const adjustBpm = (delta: number) => {
|
||||||
setBpm(bpm.value + delta)
|
setBpm(bpm.value + delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSliderChange = (e: any) => {
|
|
||||||
const v = e.detail?.value ?? e
|
|
||||||
setBpm(Number(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMeterChange = (val: number) => {
|
const onMeterChange = (val: number) => {
|
||||||
setAccentEvery(val)
|
setAccentEvery(val)
|
||||||
}
|
}
|
||||||
@@ -154,21 +117,6 @@ const togglePlay = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(() => ({
|
const ringStyle = computed(() => ({
|
||||||
'--beat-duration': `${intervalMs.value}ms`,
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
}))
|
}))
|
||||||
@@ -177,23 +125,15 @@ const coreStyle = computed(() => ({
|
|||||||
'--beat-duration': `${intervalMs.value}ms`,
|
'--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(() => {
|
const statusHint = computed(() => {
|
||||||
if (isPlaying.value) return `第 ${((beatIndex.value - 1) % accentEvery.value) + 1} 拍 / 共 ${accentEvery.value} 拍`
|
if (isPlaying.value)
|
||||||
return '点击播放按钮开始'
|
return `第 ${((beatIndex.value - 1) % accentEvery.value) + 1} 拍 / 共 ${accentEvery.value} 拍`
|
||||||
|
return '点击中间圆圈开始'
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(intervalMs, () => {})
|
onShow(() => {
|
||||||
|
preload()
|
||||||
|
})
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
@@ -203,7 +143,6 @@ onHide(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (tapHintTimer) clearTimeout(tapHintTimer)
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -211,7 +150,7 @@ onUnmounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.metronome-page {
|
.metronome-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 32rpx 32rpx 80rpx;
|
padding: 60rpx 32rpx 80rpx;
|
||||||
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -219,40 +158,20 @@ onUnmounted(() => {
|
|||||||
color: #f1f5f9;
|
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 {
|
.beat-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20rpx;
|
gap: 28rpx;
|
||||||
margin-bottom: 60rpx;
|
margin-bottom: 60rpx;
|
||||||
height: 32rpx;
|
height: 40rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.beat-dot {
|
.beat-dot {
|
||||||
width: 20rpx;
|
width: 28rpx;
|
||||||
height: 20rpx;
|
height: 28rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #334155;
|
background: #334155;
|
||||||
transition: all 0.15s;
|
transition: all 0.12s;
|
||||||
|
|
||||||
&.accent {
|
&.accent {
|
||||||
background: #475569;
|
background: #475569;
|
||||||
@@ -261,25 +180,29 @@ onUnmounted(() => {
|
|||||||
&.active {
|
&.active {
|
||||||
background: #22c55e;
|
background: #22c55e;
|
||||||
transform: scale(1.6);
|
transform: scale(1.6);
|
||||||
box-shadow: 0 0 16rpx #22c55e;
|
box-shadow: 0 0 24rpx #22c55e;
|
||||||
|
|
||||||
&.accent {
|
&.accent {
|
||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
box-shadow: 0 0 20rpx #f59e0b;
|
box-shadow: 0 0 28rpx #f59e0b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 中心脉冲圆 ===== */
|
/* ===== 中心可点击圆 ===== */
|
||||||
.pulse-stage {
|
.pulse-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 460rpx;
|
width: 560rpx;
|
||||||
height: 460rpx;
|
height: 560rpx;
|
||||||
margin-bottom: 60rpx;
|
margin-bottom: 60rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active .pulse-core {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-ring {
|
.pulse-ring {
|
||||||
@@ -287,7 +210,7 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4rpx solid rgba(34, 197, 94, 0.2);
|
border: 6rpx solid rgba(34, 197, 94, 0.2);
|
||||||
|
|
||||||
&.playing {
|
&.playing {
|
||||||
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
||||||
@@ -300,7 +223,7 @@ onUnmounted(() => {
|
|||||||
border-color: rgba(34, 197, 94, 0.6);
|
border-color: rgba(34, 197, 94, 0.6);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1.15);
|
transform: scale(1.12);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
border-color: rgba(34, 197, 94, 0);
|
border-color: rgba(34, 197, 94, 0);
|
||||||
}
|
}
|
||||||
@@ -308,16 +231,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.pulse-core {
|
.pulse-core {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 360rpx;
|
width: 460rpx;
|
||||||
height: 360rpx;
|
height: 460rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 12rpx 40rpx rgba(34, 197, 94, 0.35);
|
box-shadow: 0 16rpx 48rpx rgba(34, 197, 94, 0.4);
|
||||||
transition: transform 0.1s, background 0.15s;
|
transition: transform 0.1s, background 0.15s, box-shadow 0.15s;
|
||||||
|
|
||||||
&.playing {
|
&.playing {
|
||||||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
||||||
@@ -325,11 +248,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
&.playing.accent {
|
&.playing.accent {
|
||||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
box-shadow: 0 12rpx 40rpx rgba(245, 158, 11, 0.4);
|
box-shadow: 0 16rpx 48rpx rgba(245, 158, 11, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpm-num {
|
.bpm-num {
|
||||||
font-size: 140rpx;
|
font-size: 200rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -337,10 +260,32 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bpm-label {
|
.bpm-label {
|
||||||
font-size: 24rpx;
|
font-size: 30rpx;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
letter-spacing: 4rpx;
|
letter-spacing: 6rpx;
|
||||||
margin-top: 8rpx;
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-state {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.play-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes core-beat {
|
@keyframes core-beat {
|
||||||
@@ -349,187 +294,98 @@ onUnmounted(() => {
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
transform: scale(1.06);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== BPM 控制 ===== */
|
/* ===== BPM 步进按钮 ===== */
|
||||||
.bpm-control {
|
.bpm-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 50rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bpm-stepper {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16rpx;
|
gap: 20rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 60rpx;
|
||||||
|
|
||||||
.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 {
|
.step-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.12);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
margin-top: 12rpx;
|
justify-content: center;
|
||||||
font-size: 22rpx;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
slider {
|
&:active {
|
||||||
margin: 0;
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
border-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-num {
|
||||||
|
font-size: 44rpx;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.big .step-num {
|
||||||
|
font-size: 48rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 拍号 ===== */
|
/* ===== 拍号 ===== */
|
||||||
.meter-section {
|
.meter-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 32rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 24rpx;
|
font-size: 28rpx;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 20rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meter-options {
|
.meter-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: 20rpx;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.meter-btn {
|
.meter-btn {
|
||||||
min-width: 100rpx;
|
flex: 1;
|
||||||
height: 64rpx;
|
max-width: 180rpx;
|
||||||
line-height: 64rpx;
|
height: 100rpx;
|
||||||
padding: 0 24rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: #cbd5e1;
|
border: 2rpx solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 32rpx;
|
border-radius: 20rpx;
|
||||||
font-size: 26rpx;
|
display: flex;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.meter-label {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #22c55e;
|
background: #22c55e;
|
||||||
color: #fff;
|
border-color: #22c55e;
|
||||||
font-weight: 600;
|
|
||||||
|
.meter-label {
|
||||||
|
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 {
|
.hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 22rpx;
|
font-size: 26rpx;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
margin-top: 16rpx;
|
margin-top: 32rpx;
|
||||||
|
height: 40rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user