feat(metronome): 升级到 BackgroundAudioManager,iOS 真后台播放
iOS 微信小程序硬限制: InnerAudioContext 切到后台/锁屏一定被挂起, 即使配置 requiredBackgroundModes 也无效。 唯一支持 iOS 真后台的音频 API 是 BackgroundAudioManager。 新增预合成档位音轨(ffmpeg 拼接 click + click-wood): - loop_80bpm_2.mp3 慢走 6.0s 4 周期循环 (72 KB) - loop_110bpm_2.mp3 健走 5.45s 5 周期循环 (66 KB) - loop_130bpm_2.mp3 快走 5.54s 6 周期循环 (67 KB) - cover.jpg 200×200 emerald 占位封面 (474B) 新 hook training/hooks/useMetronomeBg.ts: - 封装 uni.getBackgroundAudioManager() 单例 - 必填 metadata: title/coverImgUrl/singer/epname/webUrl - onEnded 重赋 src 实现循环 (BgAudio 无原生 loop 属性) - onPause 同步 isPlaying,用户从锁屏控制条点暂停 UI 自动同步 - 暴露 playLoop(id)/pause/resume/stop API metronome.vue 改造: - 切换为 useMetronomeBg,移除右下角微调和拍号选择 (预合成 mp3 改不了 BPM/拍号,要么砍要么扩 9 个 mp3) - 中央圆按钮逻辑: · 未选档位 → 默认开"健走" · 选了档位且在播 → 暂停 · 选了档位且暂停 → 恢复 - 视频 playbackRate 跟当前档位 BPM 同步(80→0.65× / 110→0.89× / 130→1.05×) - 加底部提示"🔒 锁屏可继续播放,放兜里走也能听到" - onHide 不 stop,onUnmounted 才 stop(避免锁屏控制条挂死) 副作用: - 锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(老人友好) - 切档位有 200~500ms 延迟 (用户主动操作可接受) - 不再支持任意 BPM 微调 (3 档已覆盖大部分场景) 旧的 useMetronome.ts 暂时保留,如 BgAudio 真机验证 OK 再清理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'slow',
|
||||||
|
bpm: 80,
|
||||||
|
accent: 2,
|
||||||
|
src: '/training/static/audio/loop_80bpm_2.mp3',
|
||||||
|
label: '慢走 80 BPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'normal',
|
||||||
|
bpm: 110,
|
||||||
|
accent: 2,
|
||||||
|
src: '/training/static/audio/loop_110bpm_2.mp3',
|
||||||
|
label: '健走 110 BPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brisk',
|
||||||
|
bpm: 130,
|
||||||
|
accent: 2,
|
||||||
|
src: '/training/static/audio/loop_130bpm_2.mp3',
|
||||||
|
label: '快走 130 BPM',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const COVER_URL = '/training/static/audio/cover.jpg'
|
||||||
|
|
||||||
|
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 = '健走配速'
|
||||||
|
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,22 @@
|
|||||||
<!-- ===== 推荐档位(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: 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 class="hint">
|
||||||
<view class="qa-group">
|
<text>🔒 锁屏可继续播放,放兜里走也能听到</text>
|
||||||
<view class="qa-btn" @click.stop="adjustBpm(-1)">−</view>
|
|
||||||
<text class="qa-label">微调</text>
|
|
||||||
<view class="qa-btn" @click.stop="adjustBpm(1)">+</view>
|
|
||||||
</view>
|
|
||||||
<view class="qa-divider" />
|
|
||||||
<view class="qa-group">
|
|
||||||
<view
|
|
||||||
v-for="m in METER_OPTIONS"
|
|
||||||
:key="m.value"
|
|
||||||
class="qa-meter"
|
|
||||||
:class="{ active: accentEvery === m.value }"
|
|
||||||
@click.stop="onMeterChange(m.value)"
|
|
||||||
>
|
|
||||||
{{ m.label }}
|
|
||||||
</view>
|
|
||||||
<text class="qa-label">拍</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -89,7 +70,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 { useMetronome } from '../hooks/useMetronome'
|
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跑步动画视频(腾讯云 COS CDN)
|
* 跑步动画视频(腾讯云 COS CDN)
|
||||||
@@ -99,104 +80,63 @@ 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
|
||||||
|
|
||||||
/**
|
const { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS } =
|
||||||
* 推荐档位:覆盖大多数老人健走场景,一键切换 BPM + 拍号
|
useMetronomeBg()
|
||||||
* 不在档位的中间数值用右下角微调即可
|
|
||||||
*/
|
|
||||||
const PRESETS = [
|
|
||||||
{
|
|
||||||
id: 'slow',
|
|
||||||
icon: '🚶',
|
|
||||||
name: '慢走',
|
|
||||||
bpm: 80,
|
|
||||||
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 = [
|
/* 档位元数据(图标/名称/描述) */
|
||||||
{ label: '2', value: 2 },
|
const presetIcon = (id: LoopId) =>
|
||||||
{ label: '3', value: 3 },
|
id === 'slow' ? '🚶' : id === 'normal' ? '🏃♀️' : '🏃'
|
||||||
{ label: '4', value: 4 },
|
const presetName = (id: LoopId) =>
|
||||||
]
|
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
|
||||||
|
const presetDesc = (id: LoopId) =>
|
||||||
|
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
|
||||||
|
|
||||||
const metronome = useMetronome({
|
const currentPreset = computed(() =>
|
||||||
initialBpm: 80,
|
currentLoop.value
|
||||||
accentEvery: 4,
|
? LOOP_PRESETS.find((p) => p.id === currentLoop.value) ?? null
|
||||||
clickSrc: '/training/static/audio/click.mp3',
|
: null,
|
||||||
accentSrc: '/training/static/audio/click-wood.mp3',
|
)
|
||||||
})
|
|
||||||
const {
|
|
||||||
bpm,
|
|
||||||
isPlaying,
|
|
||||||
isAccent,
|
|
||||||
accentEvery,
|
|
||||||
intervalMs,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
setBpm,
|
|
||||||
setAccentEvery,
|
|
||||||
preload,
|
|
||||||
} = metronome
|
|
||||||
|
|
||||||
const adjustBpm = (delta: number) => {
|
const currentBpm = computed(() => currentPreset.value?.bpm ?? 110)
|
||||||
setBpm(bpm.value + delta)
|
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||||
}
|
|
||||||
|
|
||||||
const onMeterChange = (val: number) => {
|
/* 中央圆按钮:
|
||||||
setAccentEvery(val)
|
- 没选档位 → 默认开"健走"
|
||||||
}
|
- 选了档位且在播 → 暂停
|
||||||
|
- 选了档位且暂停 → 恢复 */
|
||||||
const togglePlay = () => {
|
const onCenterTap = () => {
|
||||||
|
if (!currentLoop.value) {
|
||||||
|
playLoop('normal')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
stop()
|
pause()
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
} else {
|
} else {
|
||||||
start()
|
resume()
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当前命中的档位(BPM + 拍号都匹配才算选中) */
|
/* 档位卡片:
|
||||||
const activePreset = computed(() => {
|
- 点未选档位 → 切到该档位并播放
|
||||||
const hit = PRESETS.find(
|
- 点当前档位 → 切到暂停/恢复 */
|
||||||
(p) => p.bpm === bpm.value && p.accent === accentEvery.value,
|
const onPresetTap = (id: LoopId) => {
|
||||||
)
|
playLoop(id)
|
||||||
return hit?.id ?? null
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
})
|
|
||||||
|
|
||||||
const applyPreset = (p: (typeof PRESETS)[number]) => {
|
|
||||||
setBpm(p.bpm)
|
|
||||||
setAccentEvery(p.accent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 视频实例与 BPM 同步 */
|
/* ============================================================
|
||||||
|
* 视频实例与 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 +147,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,27 +172,25 @@ 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()
|
/* BgAudio 全局单例,无需特殊预热,首次 setSrc 时自动初始化 */
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* onHide 故意不 stop:
|
* onHide 故意不 stop:
|
||||||
* - 切到后台 / 微信"小窗" 都会触发 onHide
|
* - 切到后台/锁屏/小窗 都会触发 onHide
|
||||||
* - 配合 pages.json 的 requiredBackgroundModes:["audio"],
|
* - 配合 BackgroundAudioManager,音频会通过系统层继续播放
|
||||||
* 小程序退到后台后节拍声仍然可以继续(健走时把手机放兜里也能听到响)
|
* - 用户能从锁屏控制条主动暂停(通过 onPause 回调同步 UI)
|
||||||
* - 真正离开节拍器页(navigateBack/重启) 会 onUnload+onUnmounted,
|
|
||||||
* 在那里清理屏幕常亮
|
|
||||||
*/
|
*/
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
/* 不再主动 stop, 让节拍器在后台继续工作 */
|
/* 不停止,让节拍声后台继续 */
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
/* 页面卸载才真正停 + 释放屏幕常亮 */
|
/* 页面被销毁(navigateBack/重启)时才真正停止
|
||||||
|
否则锁屏控制条会一直挂着无主的"节拍器" */
|
||||||
if (isPlaying.value) stop()
|
if (isPlaying.value) stop()
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
})
|
})
|
||||||
@@ -276,12 +209,8 @@ $text-3: #94a3b8;
|
|||||||
$brand: #10b981;
|
$brand: #10b981;
|
||||||
$brand-soft: #d1fae5;
|
$brand-soft: #d1fae5;
|
||||||
$brand-deep: #047857;
|
$brand-deep: #047857;
|
||||||
$accent: #f59e0b;
|
|
||||||
$accent-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);
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 页面容器
|
* 页面容器
|
||||||
@@ -365,14 +294,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;
|
||||||
@@ -434,7 +355,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 跑步小人(无边框,跟白底融合)
|
* 跑步小人
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.walker {
|
.walker {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -445,9 +366,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.walker-box {
|
.walker-box {
|
||||||
/* 1:1 矩形,跟 video 内容比例一致,消除左右黑边
|
|
||||||
background 设成 video 内容右上角的浅灰白色,
|
|
||||||
万一还有 1rpx 缝隙也不会出现刺眼黑边 */
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 260rpx;
|
width: 260rpx;
|
||||||
height: 260rpx;
|
height: 260rpx;
|
||||||
@@ -460,14 +378,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;
|
||||||
@@ -549,84 +462,16 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 右下角微调(不显眼)
|
* 底部提示
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.quick-adjust {
|
.hint {
|
||||||
position: absolute;
|
margin-top: 8rpx;
|
||||||
right: 28rpx;
|
|
||||||
bottom: 22rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14rpx;
|
|
||||||
padding: 10rpx 18rpx;
|
|
||||||
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 {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-label {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: $text-3;
|
|
||||||
margin: 0 4rpx;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-btn {
|
|
||||||
width: 40rpx;
|
|
||||||
height: 40rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(15, 23, 42, 0.05);
|
|
||||||
color: $text-2;
|
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: rgba(16, 185, 129, 0.18);
|
|
||||||
color: $brand-deep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-meter {
|
|
||||||
min-width: 36rpx;
|
|
||||||
height: 36rpx;
|
|
||||||
padding: 0 10rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: $text-3;
|
color: $text-3;
|
||||||
font-weight: 600;
|
letter-spacing: 1rpx;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
line-height: 1;
|
|
||||||
transition: all 0.15s;
|
|
||||||
|
|
||||||
&.active {
|
text {
|
||||||
background: $brand;
|
line-height: 1.6;
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-divider {
|
|
||||||
width: 1rpx;
|
|
||||||
height: 22rpx;
|
|
||||||
background: rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 474 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user