更新
Merge branch 'master' into master-5-24
This commit is contained in:
@@ -58,12 +58,17 @@
|
|||||||
"subPackages" : true
|
"subPackages" : true
|
||||||
},
|
},
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
|
<<<<<<< HEAD
|
||||||
"plugins" : {
|
"plugins" : {
|
||||||
"WechatSI" : {
|
"WechatSI" : {
|
||||||
"version" : "0.3.5",
|
"version" : "0.3.5",
|
||||||
"provider" : "wx069ba97219f66d99"
|
"provider" : "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
=======
|
||||||
|
/* 节拍器后台播放需要,iOS 必需 */
|
||||||
|
"requiredBackgroundModes" : ["audio"]
|
||||||
|
>>>>>>> master
|
||||||
},
|
},
|
||||||
"mp-alipay" : {
|
"mp-alipay" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
"navigationBarBackgroundColor": "#f8fafc",
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"backgroundColor": "#f8fafc",
|
"backgroundColor": "#f8fafc",
|
||||||
"disableScroll": true
|
"requiredBackgroundModes": ["audio"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -88,12 +88,17 @@ class AudioPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||||
|
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||||
|
const DEFAULT_CLICK_SRC =
|
||||||
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||||
|
|
||||||
export function useMetronome(options: MetronomeOptions = {}) {
|
export function useMetronome(options: MetronomeOptions = {}) {
|
||||||
const {
|
const {
|
||||||
initialBpm = 80,
|
initialBpm = 80,
|
||||||
bpmMin = 40,
|
bpmMin = 40,
|
||||||
bpmMax = 240,
|
bpmMax = 240,
|
||||||
clickSrc = '/training/static/audio/click.mp3',
|
clickSrc = DEFAULT_CLICK_SRC,
|
||||||
accentSrc,
|
accentSrc,
|
||||||
poolSize = 4,
|
poolSize = 4,
|
||||||
onBeat,
|
onBeat,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节拍器后台播放专版 - 基于 BackgroundAudioManager
|
||||||
|
*
|
||||||
|
* 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制:
|
||||||
|
* - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效
|
||||||
|
* - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API
|
||||||
|
*
|
||||||
|
* 取舍:
|
||||||
|
* - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复
|
||||||
|
* - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍)
|
||||||
|
* 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环
|
||||||
|
*
|
||||||
|
* 副作用:
|
||||||
|
* - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停)
|
||||||
|
* - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages)
|
||||||
|
* - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LoopId = 'slow' | 'normal' | 'brisk'
|
||||||
|
|
||||||
|
export interface LoopPreset {
|
||||||
|
id: LoopId
|
||||||
|
bpm: number
|
||||||
|
accent: number
|
||||||
|
src: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源
|
||||||
|
* 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名
|
||||||
|
*
|
||||||
|
* 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou):
|
||||||
|
* - loop_80bpm_2.mp3 循环音轨 慢走档
|
||||||
|
* - loop_110bpm_2.mp3 循环音轨 健走档
|
||||||
|
* - loop_130bpm_2.mp3 循环音轨 快走档
|
||||||
|
*
|
||||||
|
* 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积,
|
||||||
|
* 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理
|
||||||
|
*/
|
||||||
|
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'slow',
|
||||||
|
bpm: 80,
|
||||||
|
accent: 2,
|
||||||
|
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
|
||||||
|
label: '慢走 80 BPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'normal',
|
||||||
|
bpm: 110,
|
||||||
|
accent: 2,
|
||||||
|
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
|
||||||
|
label: '健走 110 BPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brisk',
|
||||||
|
bpm: 130,
|
||||||
|
accent: 2,
|
||||||
|
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
|
||||||
|
label: '快走 130 BPM',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锁屏控制条封面
|
||||||
|
* 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里
|
||||||
|
* 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名
|
||||||
|
*/
|
||||||
|
const COVER_URL = ''
|
||||||
|
|
||||||
|
export function useMetronomeBg() {
|
||||||
|
const isPlaying = ref<boolean>(false)
|
||||||
|
const currentLoop = ref<LoopId | null>(null)
|
||||||
|
|
||||||
|
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
|
||||||
|
let bgm: any = null
|
||||||
|
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
|
||||||
|
let desiredSrc = ''
|
||||||
|
|
||||||
|
/* 懒初始化 BackgroundAudioManager + 绑定事件
|
||||||
|
BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */
|
||||||
|
const ensureBgm = () => {
|
||||||
|
if (bgm) return bgm
|
||||||
|
|
||||||
|
const m = uni.getBackgroundAudioManager()
|
||||||
|
|
||||||
|
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
|
||||||
|
m.title = '节拍器'
|
||||||
|
m.epname = '甄养堂 · 健走'
|
||||||
|
m.singer = '健走配速'
|
||||||
|
if (COVER_URL) m.coverImgUrl = COVER_URL
|
||||||
|
m.webUrl = ''
|
||||||
|
|
||||||
|
m.onPlay(() => {
|
||||||
|
isPlaying.value = true
|
||||||
|
})
|
||||||
|
m.onPause(() => {
|
||||||
|
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
|
||||||
|
isPlaying.value = false
|
||||||
|
})
|
||||||
|
m.onStop(() => {
|
||||||
|
isPlaying.value = false
|
||||||
|
currentLoop.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
/* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放
|
||||||
|
BgAudio 没有原生 loop 属性,只能用这招 */
|
||||||
|
m.onEnded(() => {
|
||||||
|
if (desiredSrc && isPlaying.value) {
|
||||||
|
try {
|
||||||
|
m.src = desiredSrc
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
m.onError((err) => {
|
||||||
|
console.error('[BgAudio] error:', err)
|
||||||
|
isPlaying.value = false
|
||||||
|
uni.showToast({
|
||||||
|
title: '音频播放失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
bgm = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切到指定档位并开始播放
|
||||||
|
* 如果已经在播同一档位 → 切到 pause/play 状态
|
||||||
|
* 如果在播别的档位 → 切换 src(有 200~500ms 延迟)
|
||||||
|
*/
|
||||||
|
const playLoop = (id: LoopId) => {
|
||||||
|
const preset = LOOP_PRESETS.find((p) => p.id === id)
|
||||||
|
if (!preset) return
|
||||||
|
|
||||||
|
const m = ensureBgm()
|
||||||
|
|
||||||
|
/* 同档位再点一下 = 暂停 */
|
||||||
|
if (currentLoop.value === id && isPlaying.value) {
|
||||||
|
m.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 切换档位或从暂停恢复 */
|
||||||
|
currentLoop.value = id
|
||||||
|
desiredSrc = preset.src
|
||||||
|
|
||||||
|
/* 重设 title 让锁屏控制条显示当前档位 */
|
||||||
|
m.title = `节拍器 · ${preset.label}`
|
||||||
|
|
||||||
|
/* 赋值 src 会自动播放(微信 API 设计如此) */
|
||||||
|
m.src = preset.src
|
||||||
|
/* isPlaying 由 onPlay 回调置 true */
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
if (bgm && isPlaying.value) {
|
||||||
|
bgm.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
if (bgm && !isPlaying.value && desiredSrc) {
|
||||||
|
/* 从暂停态恢复:直接 play 即可 */
|
||||||
|
try {
|
||||||
|
bgm.play()
|
||||||
|
} catch (_) {
|
||||||
|
/* 部分基础库 play() 不可用时,重赋 src */
|
||||||
|
bgm.src = desiredSrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完全停止 + 清掉锁屏控制条
|
||||||
|
* 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例
|
||||||
|
*/
|
||||||
|
const stop = () => {
|
||||||
|
if (bgm) {
|
||||||
|
try {
|
||||||
|
bgm.stop()
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
currentLoop.value = null
|
||||||
|
desiredSrc = ''
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hook 卸载时不主动 stop,因为用户离开节拍器页时
|
||||||
|
仍希望音乐持续(走在路上拿出手机切别的页面应该不停)
|
||||||
|
真正停止的责任在 metronome.vue 的退出按钮里 */
|
||||||
|
onUnmounted(() => {
|
||||||
|
/* 仅解绑回调? BgAudio 是全局单例,我们的回调还在,
|
||||||
|
不解会导致内存中保留无用引用,但回调里都判断了 isPlaying,
|
||||||
|
且新页面再 ensureBgm 时会覆盖回调,可接受 */
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying,
|
||||||
|
currentLoop,
|
||||||
|
playLoop,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
LOOP_PRESETS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page" :style="rootStyle">
|
<view class="page" :style="rootStyle">
|
||||||
<!-- ===== 主舞台:节拍器圆 ===== -->
|
<!-- ===== 主舞台:节拍器圆 ===== -->
|
||||||
<view class="stage" @click="togglePlay">
|
<view class="stage" @click="onCenterTap">
|
||||||
<view class="ring r1" :class="{ playing: isPlaying }" />
|
<view class="ring r1" :class="{ playing: isPlaying }" />
|
||||||
<view class="ring r2" :class="{ playing: isPlaying }" />
|
<view class="ring r2" :class="{ playing: isPlaying }" />
|
||||||
<view class="core" :class="{ playing: isPlaying, accent: isAccent }">
|
<view class="core" :class="{ playing: isPlaying }">
|
||||||
<text class="bpm-num">{{ bpm }}</text>
|
<text class="bpm-num">{{ currentBpm }}</text>
|
||||||
<text class="bpm-label">BPM</text>
|
<text class="bpm-label">BPM</text>
|
||||||
<view class="ctrl">
|
<view class="ctrl">
|
||||||
<view v-if="isPlaying" class="icon-pause">
|
<view v-if="isPlaying" class="icon-pause">
|
||||||
@@ -38,10 +38,8 @@
|
|||||||
@error="onVideoError"
|
@error="onVideoError"
|
||||||
@loadedmetadata="onVideoLoaded"
|
@loadedmetadata="onVideoLoaded"
|
||||||
/>
|
/>
|
||||||
<!-- 微信原生 video 右上角的 PIP/小窗按钮无法靠属性彻底关掉,
|
|
||||||
用 cover-view 覆盖一个跟背景同色的小色块盖住它 -->
|
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<!-- <cover-view class="pip-mask"> </cover-view> -->
|
<cover-view class="pip-mask"> </cover-view>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -49,39 +47,59 @@
|
|||||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||||
<view class="presets">
|
<view class="presets">
|
||||||
<view
|
<view
|
||||||
v-for="p in PRESETS"
|
v-for="p in LOOP_PRESETS"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
class="preset"
|
class="preset"
|
||||||
:class="{ active: activePreset === p.id }"
|
:class="{ active: mode === 'preset' && currentLoop === p.id }"
|
||||||
@click="applyPreset(p)"
|
@click="onPresetTap(p.id)"
|
||||||
>
|
>
|
||||||
<text class="preset-icon">{{ p.icon }}</text>
|
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
|
||||||
<text class="preset-name">{{ p.name }}</text>
|
<text class="preset-name">{{ presetName(p.id) }}</text>
|
||||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||||
<text class="preset-desc">{{ p.desc }}</text>
|
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ===== 右下角微调(不显眼) ===== -->
|
<!-- ===== 自定义节奏(高级折叠区) ===== -->
|
||||||
<view class="quick-adjust">
|
<view v-if="customExpanded" class="custom-panel">
|
||||||
<view class="qa-group">
|
<view class="custom-warn">
|
||||||
<view class="qa-btn" @click.stop="adjustBpm(-1)">−</view>
|
<text class="warn-dot">●</text>
|
||||||
<text class="qa-label">微调</text>
|
<text class="warn-text">前台模式 · 切后台会暂停</text>
|
||||||
<view class="qa-btn" @click.stop="adjustBpm(1)">+</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="qa-divider" />
|
|
||||||
<view class="qa-group">
|
<!-- BPM 微调 -->
|
||||||
<view
|
<view class="row">
|
||||||
v-for="m in METER_OPTIONS"
|
<text class="row-label">BPM</text>
|
||||||
:key="m.value"
|
<view class="bpm-stepper">
|
||||||
class="qa-meter"
|
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||||
:class="{ active: accentEvery === m.value }"
|
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||||
@click.stop="onMeterChange(m.value)"
|
<view class="step-val">{{ customBpm }}</view>
|
||||||
>
|
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||||
{{ m.label }}
|
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="qa-label">拍</text>
|
|
||||||
</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 class="bottom-row">
|
||||||
|
<text class="bottom-link" @click="onToggleCustom">
|
||||||
|
{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}
|
||||||
|
</text>
|
||||||
|
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -89,6 +107,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
||||||
import { useMetronome } from '../hooks/useMetronome'
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,104 +118,148 @@ import { useMetronome } from '../hooks/useMetronome'
|
|||||||
const RUNNER_VIDEO_URL =
|
const RUNNER_VIDEO_URL =
|
||||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
||||||
|
|
||||||
/**
|
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
||||||
* 视频原速对应的 BPM 基准(参见 hooks 实现注释)
|
|
||||||
* playback-rate 范围 [0.5, 2.0]
|
|
||||||
*/
|
|
||||||
const VIDEO_BASE_BPM = 124
|
const VIDEO_BASE_BPM = 124
|
||||||
|
|
||||||
/**
|
/* ============================================================
|
||||||
* 推荐档位:覆盖大多数老人健走场景,一键切换 BPM + 拍号
|
* 双引擎设计
|
||||||
* 不在档位的中间数值用右下角微调即可
|
* ------------------------------------------------------------
|
||||||
*/
|
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
|
||||||
const PRESETS = [
|
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
|
||||||
{
|
* - 切模式时停掉对侧引擎,避免双声道叠加
|
||||||
id: 'slow',
|
* ============================================================ */
|
||||||
icon: '🚶',
|
type Mode = 'preset' | 'custom'
|
||||||
name: '慢走',
|
const mode = ref<Mode>('preset')
|
||||||
bpm: 80,
|
const customExpanded = ref<boolean>(false)
|
||||||
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 = [
|
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
|
||||||
{ label: '2', value: 2 },
|
const CLICK_WOOD_URL =
|
||||||
{ label: '3', value: 3 },
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
|
||||||
{ label: '4', value: 4 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const metronome = useMetronome({
|
const bg = useMetronomeBg()
|
||||||
initialBpm: 80,
|
const fg = useMetronome({
|
||||||
accentEvery: 4,
|
initialBpm: 100,
|
||||||
clickSrc: '/training/static/audio/click.mp3',
|
accentEvery: 2,
|
||||||
accentSrc: '/training/static/audio/click-wood.mp3',
|
clickSrc: CLICK_WOOD_URL,
|
||||||
|
accentSrc: CLICK_WOOD_URL,
|
||||||
|
poolSize: 4,
|
||||||
})
|
})
|
||||||
const {
|
|
||||||
bpm,
|
|
||||||
isPlaying,
|
|
||||||
isAccent,
|
|
||||||
accentEvery,
|
|
||||||
intervalMs,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
setBpm,
|
|
||||||
setAccentEvery,
|
|
||||||
preload,
|
|
||||||
} = metronome
|
|
||||||
|
|
||||||
const adjustBpm = (delta: number) => {
|
const { LOOP_PRESETS } = bg
|
||||||
setBpm(bpm.value + delta)
|
const currentLoop = bg.currentLoop
|
||||||
}
|
|
||||||
|
|
||||||
const onMeterChange = (val: number) => {
|
const customBpm = computed(() => fg.bpm.value)
|
||||||
setAccentEvery(val)
|
const customAccent = computed(() => fg.accentEvery.value)
|
||||||
}
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
|
||||||
if (isPlaying.value) {
|
const isPlaying = computed(() =>
|
||||||
stop()
|
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
)
|
||||||
|
|
||||||
|
const currentBpm = computed(() => {
|
||||||
|
if (mode.value === 'preset') {
|
||||||
|
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
|
||||||
|
return p?.bpm ?? 110
|
||||||
|
}
|
||||||
|
return fg.bpm.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 档位元数据
|
||||||
|
* ============================================================ */
|
||||||
|
const presetIcon = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '🚶' : id === 'normal' ? '🏃♀️' : '🏃'
|
||||||
|
const presetName = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
|
||||||
|
const presetDesc = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 中央圆按钮:在两个模式下分别工作
|
||||||
|
* ============================================================ */
|
||||||
|
const onCenterTap = () => {
|
||||||
|
if (mode.value === 'preset') {
|
||||||
|
if (!currentLoop.value) {
|
||||||
|
bg.playLoop('normal')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bg.isPlaying.value) {
|
||||||
|
bg.pause()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
bg.resume()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
start()
|
/* custom 模式 */
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
if (fg.isPlaying.value) {
|
||||||
|
fg.stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
fg.start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当前命中的档位(BPM + 拍号都匹配才算选中) */
|
/* ============================================================
|
||||||
const activePreset = computed(() => {
|
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
|
||||||
const hit = PRESETS.find(
|
* ============================================================ */
|
||||||
(p) => p.bpm === bpm.value && p.accent === accentEvery.value,
|
const onPresetTap = (id: LoopId) => {
|
||||||
)
|
if (mode.value === 'custom') {
|
||||||
return hit?.id ?? null
|
fg.stop()
|
||||||
})
|
mode.value = 'preset'
|
||||||
|
customExpanded.value = false
|
||||||
const applyPreset = (p: (typeof PRESETS)[number]) => {
|
}
|
||||||
setBpm(p.bpm)
|
bg.playLoop(id)
|
||||||
setAccentEvery(p.accent)
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 视频实例与 BPM 同步 */
|
/* ============================================================
|
||||||
|
* 自定义折叠区切换
|
||||||
|
* ============================================================ */
|
||||||
|
const onToggleCustom = () => {
|
||||||
|
if (!customExpanded.value) {
|
||||||
|
/* 展开:进入 custom 模式 + 停掉 preset */
|
||||||
|
if (bg.isPlaying.value || currentLoop.value) {
|
||||||
|
bg.stop()
|
||||||
|
}
|
||||||
|
mode.value = 'custom'
|
||||||
|
/* 把当前显示 BPM 带过去当起始值 */
|
||||||
|
const seed =
|
||||||
|
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
|
||||||
|
fg.setBpm(seed)
|
||||||
|
fg.setAccentEvery(2)
|
||||||
|
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
|
||||||
|
fg.preload()
|
||||||
|
customExpanded.value = true
|
||||||
|
} else {
|
||||||
|
/* 收起:回到 preset,停掉 fg */
|
||||||
|
fg.stop()
|
||||||
|
mode.value = 'preset'
|
||||||
|
customExpanded.value = false
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBpmDelta = (delta: number) => {
|
||||||
|
fg.setBpm(fg.bpm.value + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAccentSelect = (n: number) => {
|
||||||
|
fg.setAccentEvery(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 视频实例与 BPM 同步
|
||||||
|
* ============================================================ */
|
||||||
let videoCtx: UniApp.VideoContext | null = null
|
let videoCtx: UniApp.VideoContext | null = null
|
||||||
|
|
||||||
const playbackRate = computed(() => {
|
const playbackRate = computed(() => {
|
||||||
const rate = bpm.value / VIDEO_BASE_BPM
|
const rate = currentBpm.value / VIDEO_BASE_BPM
|
||||||
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -207,11 +270,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
const onVideoError = (e: any) => {
|
const onVideoError = (e: any) => {
|
||||||
console.error('[runner-video] error:', e?.detail || e)
|
console.error('[runner-video] error:', e?.detail || e)
|
||||||
uni.showToast({
|
|
||||||
title: '视频加载失败,请用真机预览',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2500,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVideoLoaded = (e: any) => {
|
const onVideoLoaded = (e: any) => {
|
||||||
@@ -237,21 +295,31 @@ watch(playbackRate, (rate) => {
|
|||||||
|
|
||||||
const rootStyle = computed(() => ({
|
const rootStyle = computed(() => ({
|
||||||
'--beat-duration': `${intervalMs.value}ms`,
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
'--step-duration': `${intervalMs.value}ms`,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
preload()
|
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
|
||||||
|
if (mode.value === 'custom') {
|
||||||
|
fg.preload()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onHide:差异化处理
|
||||||
|
* - preset 模式: 不停,BgAudio 接管后台播放
|
||||||
|
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
|
||||||
|
*/
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
if (isPlaying.value) {
|
if (mode.value === 'custom') {
|
||||||
stop()
|
fg.stop()
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
/* 页面销毁时全部清理 */
|
||||||
|
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
|
||||||
|
if (mode.value === 'custom') fg.stop()
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -269,28 +337,25 @@ $text-3: #94a3b8;
|
|||||||
$brand: #10b981;
|
$brand: #10b981;
|
||||||
$brand-soft: #d1fae5;
|
$brand-soft: #d1fae5;
|
||||||
$brand-deep: #047857;
|
$brand-deep: #047857;
|
||||||
$accent: #f59e0b;
|
$warn: #f59e0b;
|
||||||
$accent-soft: #fef3c7;
|
$warn-soft: #fef3c7;
|
||||||
$radius-card: 24rpx;
|
$radius-card: 24rpx;
|
||||||
$radius-btn: 18rpx;
|
|
||||||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||||
$shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 页面容器
|
* 页面容器
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.page {
|
.page {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 30rpx 28rpx 80rpx;
|
padding: 30rpx 28rpx 50rpx;
|
||||||
background: $bg-color;
|
background: $bg-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 22rpx;
|
gap: 20rpx;
|
||||||
color: $text-1;
|
color: $text-1;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -300,7 +365,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 480rpx;
|
width: 480rpx;
|
||||||
height: 480rpx;
|
height: 480rpx;
|
||||||
margin-top: 16rpx;
|
margin-top: 12rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -358,14 +423,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
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 {
|
.bpm-num {
|
||||||
font-size: 168rpx;
|
font-size: 168rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -427,23 +484,20 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 跑步小人(无边框,跟白底融合)
|
* 跑步小人
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.walker {
|
.walker {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 260rpx;
|
height: 240rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.walker-box {
|
.walker-box {
|
||||||
/* 1:1 矩形,跟 video 内容比例一致,消除左右黑边
|
|
||||||
background 设成 video 内容右上角的浅灰白色,
|
|
||||||
万一还有 1rpx 缝隙也不会出现刺眼黑边 */
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 260rpx;
|
width: 240rpx;
|
||||||
height: 260rpx;
|
height: 240rpx;
|
||||||
background: #e8ecf2;
|
background: #e8ecf2;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -453,14 +507,9 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
.runner-video {
|
.runner-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* 微信 native video 自身默认是 #000 黑底,父容器 bg 在 web 看不到,
|
|
||||||
只能让 video 占满 + 让父 bg 跟 video 内容色融合 */
|
|
||||||
background: #e8ecf2;
|
background: #e8ecf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 覆盖在 video 右上角原生 PIP/小窗按钮上的色块
|
|
||||||
颜色取 video 内容右上角的浅灰白渐变(#dde2eb 左右),让色块融入视频画面
|
|
||||||
仅小程序端有效(cover-view 是小程序唯一能盖住 native 组件的元素) */
|
|
||||||
.pip-mask {
|
.pip-mask {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -478,7 +527,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
.presets {
|
.presets {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset {
|
.preset {
|
||||||
@@ -486,33 +535,33 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
background: $card-bg;
|
background: $card-bg;
|
||||||
border: 2rpx solid $card-border;
|
border: 2rpx solid $card-border;
|
||||||
border-radius: $radius-card;
|
border-radius: $radius-card;
|
||||||
padding: 22rpx 12rpx 20rpx;
|
padding: 18rpx 8rpx 16rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
gap: 4rpx;
|
||||||
box-shadow: $shadow-sm;
|
box-shadow: $shadow-sm;
|
||||||
transition: all 0.18s;
|
transition: all 0.18s;
|
||||||
|
|
||||||
.preset-icon {
|
.preset-icon {
|
||||||
font-size: 44rpx;
|
font-size: 40rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: 6rpx;
|
margin-bottom: 4rpx;
|
||||||
}
|
}
|
||||||
.preset-name {
|
.preset-name {
|
||||||
font-size: 30rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $text-1;
|
color: $text-1;
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
}
|
}
|
||||||
.preset-bpm {
|
.preset-bpm {
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
color: $text-2;
|
color: $text-2;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.preset-desc {
|
.preset-desc {
|
||||||
font-size: 20rpx;
|
font-size: 18rpx;
|
||||||
color: $text-3;
|
color: $text-3;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
margin-top: 2rpx;
|
margin-top: 2rpx;
|
||||||
@@ -542,84 +591,145 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 右下角微调(不显眼)
|
* 自定义节奏面板(仅 customExpanded 时显示)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.quick-adjust {
|
.custom-panel {
|
||||||
position: absolute;
|
width: 100%;
|
||||||
right: 28rpx;
|
background: $card-bg;
|
||||||
bottom: 22rpx;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-warn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14rpx;
|
gap: 8rpx;
|
||||||
padding: 10rpx 18rpx;
|
margin-bottom: 4rpx;
|
||||||
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 {
|
.warn-dot {
|
||||||
opacity: 1;
|
font-size: 18rpx;
|
||||||
|
color: $warn;
|
||||||
|
}
|
||||||
|
.warn-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #b45309;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-group {
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
}
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
.qa-label {
|
.step-btn {
|
||||||
font-size: 20rpx;
|
min-width: 56rpx;
|
||||||
color: $text-3;
|
height: 52rpx;
|
||||||
margin: 0 4rpx;
|
line-height: 52rpx;
|
||||||
letter-spacing: 1rpx;
|
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;
|
||||||
|
|
||||||
.qa-btn {
|
&:active {
|
||||||
width: 40rpx;
|
background: #e2e8f0;
|
||||||
height: 40rpx;
|
transform: scale(0.94);
|
||||||
border-radius: 50%;
|
}
|
||||||
background: rgba(15, 23, 42, 0.05);
|
}
|
||||||
color: $text-2;
|
.step-val {
|
||||||
font-size: 26rpx;
|
min-width: 84rpx;
|
||||||
font-weight: 700;
|
height: 52rpx;
|
||||||
display: flex;
|
line-height: 52rpx;
|
||||||
align-items: center;
|
text-align: center;
|
||||||
justify-content: center;
|
font-size: 30rpx;
|
||||||
line-height: 1;
|
font-weight: 800;
|
||||||
|
color: $text-1;
|
||||||
&:active {
|
font-variant-numeric: tabular-nums;
|
||||||
background: rgba(16, 185, 129, 0.18);
|
background: $warn-soft;
|
||||||
color: $brand-deep;
|
border-radius: 10rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-meter {
|
.meter-tabs {
|
||||||
min-width: 36rpx;
|
display: flex;
|
||||||
height: 36rpx;
|
gap: 8rpx;
|
||||||
padding: 0 10rpx;
|
|
||||||
border-radius: 999rpx;
|
.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;
|
font-size: 22rpx;
|
||||||
color: $text-3;
|
color: $text-3;
|
||||||
font-weight: 600;
|
letter-spacing: 0.5rpx;
|
||||||
display: flex;
|
padding: 8rpx 4rpx;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
transition: all 0.15s;
|
|
||||||
|
|
||||||
&.active {
|
&:active {
|
||||||
background: $brand;
|
color: $text-2;
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-divider {
|
.bottom-hint {
|
||||||
width: 1rpx;
|
font-size: 22rpx;
|
||||||
height: 22rpx;
|
color: $text-3;
|
||||||
background: rgba(15, 23, 42, 0.08);
|
letter-spacing: 0.5rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,26 +6,16 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
training/static/
|
training/static/
|
||||||
├── audio/
|
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||||
│ └── click.mp3 # 节拍器主音(短促金属敲击声,约 50ms)
|
├── voice/ # (规划中) TTS 语音教练
|
||||||
├── voice/
|
│ ├── numbers/ 数字报数 1~30
|
||||||
│ ├── numbers/
|
│ └── prompts/ 开始/休息/再来 等口令
|
||||||
│ │ └── 1.mp3 ~ 30.mp3 # 数字报数
|
├── bgm/ # (规划中) 训练/休息背景乐
|
||||||
│ └── prompts/
|
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||||
│ ├── start.mp3
|
|
||||||
│ ├── ready.mp3
|
|
||||||
│ ├── rest.mp3
|
|
||||||
│ ├── next-set.mp3
|
|
||||||
│ ├── last-rep.mp3
|
|
||||||
│ ├── keep-it-up.mp3
|
|
||||||
│ ├── good-job.mp3
|
|
||||||
│ ├── breathe-in.mp3
|
|
||||||
│ └── breathe-out.mp3
|
|
||||||
└── bgm/
|
|
||||||
├── train-light.mp3 # 训练时背景乐(轻快有节奏感)
|
|
||||||
└── rest-meditation.mp3 # 休息时背景乐(舒缓冥想)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||||
|
|
||||||
## 准备步骤
|
## 准备步骤
|
||||||
|
|
||||||
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||||
@@ -63,21 +53,22 @@ node scripts/generate-voice.mjs --force
|
|||||||
- `苏打`:年轻男声,有力量感
|
- `苏打`:年轻男声,有力量感
|
||||||
- `白桦`:成熟男声,沉稳
|
- `白桦`:成熟男声,沉稳
|
||||||
|
|
||||||
### 2. 节拍器 click 音
|
### 2. 节拍器 click / 循环音轨
|
||||||
|
|
||||||
**已内置 3 种音色**(来自 [chrono-bump](https://github.com/johnnysn/chrono-bump) MIT 协议项目):
|
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||||
|
|
||||||
| 文件 | 用途 | 大小 |
|
| 用途 | 文件 | 大小 | 引擎 |
|
||||||
|------|------|------|
|
|---|---|---|---|
|
||||||
| `audio/click.mp3` | 默认 click(标准节拍器音) | 3.4 KB |
|
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||||
| `audio/click-soft.mp3` | 柔和音色 | 4.2 KB |
|
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||||
| `audio/click-wood.mp3` | 木鱼/原木质感(推荐用于重音 accent) | 4.6 KB |
|
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||||
|
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||||
|
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||||
|
|
||||||
如果想换更好听的,可以去:
|
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||||
- [freesound.org](https://freesound.org) 搜 "click" / "tick"(注意 CC 协议)
|
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||||
- [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 搜 "metronome"(免费可商用)
|
|
||||||
|
|
||||||
替换时保持文件名不变即可,无需改代码。
|
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||||
|
|
||||||
### 3. 背景音乐
|
### 3. 背景音乐
|
||||||
|
|
||||||
@@ -92,9 +83,11 @@ node scripts/generate-voice.mjs --force
|
|||||||
|
|
||||||
## 在代码里被引用的位置
|
## 在代码里被引用的位置
|
||||||
|
|
||||||
- `training/hooks/useMetronome.ts` → `audio/click.mp3`
|
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||||
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3`
|
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||||
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3`
|
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||||
|
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||||
|
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||||
|
|
||||||
如需修改路径,修改对应 hook 文件里的常量即可。
|
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||||
|
|
||||||
@@ -103,3 +96,120 @@ node scripts/generate-voice.mjs --force
|
|||||||
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||||
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||||
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后台/锁屏播放设计备忘(未来健身模块用)
|
||||||
|
|
||||||
|
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
|
||||||
|
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
|
||||||
|
|
||||||
|
### 引擎选择对照表
|
||||||
|
|
||||||
|
健身模块的音频天然分两类,配两套引擎不冲突:
|
||||||
|
|
||||||
|
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
|
||||||
|
| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
|
||||||
|
| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
|
||||||
|
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
|
||||||
|
|
||||||
|
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
|
||||||
|
|
||||||
|
### BackgroundAudioManager 限制清单(踩坑预警)
|
||||||
|
|
||||||
|
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
|
||||||
|
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
|
||||||
|
3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
|
||||||
|
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
|
||||||
|
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
|
||||||
|
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min)
|
||||||
|
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
|
||||||
|
|
||||||
|
### 推荐架构(健身模块上线时)
|
||||||
|
|
||||||
|
```
|
||||||
|
training/hooks/
|
||||||
|
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
|
||||||
|
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
|
||||||
|
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
|
||||||
|
```
|
||||||
|
|
||||||
|
页面 `pages.json` 声明:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "pages/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "练一练",
|
||||||
|
"requiredBackgroundModes": ["audio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
|
||||||
|
|
||||||
|
### 必备资产清单
|
||||||
|
|
||||||
|
到时候要准备的文件:
|
||||||
|
|
||||||
|
- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
|
||||||
|
- 每个 30s~1min,128kbps 单声道,30~80KB
|
||||||
|
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- **锁屏封面**:`audio/cover.jpg` 200×200
|
||||||
|
- 简洁绿色背景 + 训练 emoji 即可
|
||||||
|
|
||||||
|
### BackgroundAudioManager API 速查
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const bgm = uni.getBackgroundAudioManager()
|
||||||
|
|
||||||
|
/* 必填 metadata,缺一会报错 */
|
||||||
|
bgm.title = '健走训练中'
|
||||||
|
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
|
||||||
|
bgm.epname = '甄养堂'
|
||||||
|
bgm.singer = '健走节拍'
|
||||||
|
bgm.webUrl = '' // 必填,空字符串可
|
||||||
|
|
||||||
|
/* src 一旦赋值会自动播放 */
|
||||||
|
bgm.src = '/training/static/audio/bgm/train-light.mp3'
|
||||||
|
|
||||||
|
/* 控制 */
|
||||||
|
bgm.pause() // 暂停 (锁屏控制条仍在)
|
||||||
|
bgm.play() // 继续
|
||||||
|
bgm.stop() // 真停 + 隐藏控制条
|
||||||
|
bgm.seek(30) // 跳到 30s
|
||||||
|
|
||||||
|
/* 事件监听 — 用户从锁屏点暂停时会触发 */
|
||||||
|
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
|
||||||
|
bgm.onPlay(() => {})
|
||||||
|
bgm.onStop(() => {})
|
||||||
|
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
|
||||||
|
bgm.onError((err) => { console.error(err) })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 切换不同场景音乐的模式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function switchBgm(scene: 'training' | 'resting' | 'none') {
|
||||||
|
if (scene === 'none') {
|
||||||
|
bgm.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
|
||||||
|
bgm.src = scene === 'training'
|
||||||
|
? '/training/static/audio/bgm/train-light.mp3'
|
||||||
|
: '/training/static/audio/bgm/rest-meditation.mp3'
|
||||||
|
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 节拍器要不要也升级到 BgAudio?
|
||||||
|
|
||||||
|
**目前不需要**。节拍器升级 BgAudio 的代价:
|
||||||
|
- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍)
|
||||||
|
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
|
||||||
|
- 切档位有 200~500ms 卡顿
|
||||||
|
|
||||||
|
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -80,7 +80,8 @@ return [
|
|||||||
|
|
||||||
//上传文件的格式 (文件)
|
//上传文件的格式 (文件)
|
||||||
'file_file' => [
|
'file_file' => [
|
||||||
'zip','rar','txt','pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','ftr','7z','gz'
|
'zip','rar','txt','pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','ftr','7z','gz',
|
||||||
|
'mp3', 'wav', 'm4a', 'aac', 'amr', 'wma', // 音频
|
||||||
],
|
],
|
||||||
|
|
||||||
// 登录设置
|
// 登录设置
|
||||||
|
|||||||
Reference in New Issue
Block a user