1
This commit is contained in:
@@ -1,213 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<template>
|
||||||
|
<view class="walker-canvas-container">
|
||||||
|
<canvas
|
||||||
|
canvas-id="walker"
|
||||||
|
id="walker"
|
||||||
|
class="walker-canvas"
|
||||||
|
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
@touchcancel="onTouchCancel"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
isPlaying: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
bpm: {
|
||||||
|
type: Number,
|
||||||
|
default: 110
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvasWidth: 320,
|
||||||
|
canvasHeight: 400,
|
||||||
|
ctx: null,
|
||||||
|
timer: null,
|
||||||
|
renderRunning: false,
|
||||||
|
|
||||||
|
// Interaction State (Spring Physics)
|
||||||
|
coreScale: 1,
|
||||||
|
coreTargetScale: 1,
|
||||||
|
coreVelocity: 0,
|
||||||
|
pressed: false,
|
||||||
|
|
||||||
|
// Animation Physics (Time)
|
||||||
|
time: 0,
|
||||||
|
lastFrameTime: 0,
|
||||||
|
|
||||||
|
// Metronome State
|
||||||
|
lastBeatTime: 0,
|
||||||
|
|
||||||
|
// Energy Waves
|
||||||
|
waveAmplitudeMultiplier: 1,
|
||||||
|
|
||||||
|
// Shockwaves
|
||||||
|
shockwaves: [],
|
||||||
|
|
||||||
|
// Rotation for outer ring
|
||||||
|
ringAngle: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initCanvas();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.renderRunning = false;
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isPlaying(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.waveAmplitudeMultiplier = 1;
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initCanvas() {
|
||||||
|
// Delay to ensure Flexbox layout is fully complete before measuring
|
||||||
|
setTimeout(() => {
|
||||||
|
const query = uni.createSelectorQuery().in(this);
|
||||||
|
query.select('.walker-canvas-container').boundingClientRect(data => {
|
||||||
|
if (data && data.width > 0) {
|
||||||
|
this.canvasWidth = data.width;
|
||||||
|
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
|
||||||
|
} else {
|
||||||
|
const sys = uni.getSystemInfoSync();
|
||||||
|
this.canvasWidth = sys.windowWidth - 30; // approximate padding
|
||||||
|
this.canvasHeight = this.canvasWidth * 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ctx) {
|
||||||
|
this.ctx = uni.createCanvasContext('walker', this);
|
||||||
|
this.lastFrameTime = Date.now();
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
}
|
||||||
|
}).exec();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expose a method to be called from parent when a beat hits
|
||||||
|
triggerBeat() {
|
||||||
|
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||||
|
// Add a shockwave
|
||||||
|
this.shockwaves.push({
|
||||||
|
radius: coreRadius,
|
||||||
|
maxRadius: coreRadius * 2.2,
|
||||||
|
opacity: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spike the wave amplitude
|
||||||
|
this.waveAmplitudeMultiplier = 2.5;
|
||||||
|
|
||||||
|
// Slight pulse to the core
|
||||||
|
this.coreVelocity -= 0.05;
|
||||||
|
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
// Center
|
||||||
|
const cx = this.canvasWidth / 2;
|
||||||
|
const cy = this.canvasHeight / 2 - 30;
|
||||||
|
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||||
|
|
||||||
|
const dx = touch.x - cx;
|
||||||
|
const dy = touch.y - cy;
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
|
||||||
|
// Hit box slightly larger than the visual circle
|
||||||
|
if (dist <= coreRadius + 40) {
|
||||||
|
this.pressed = true;
|
||||||
|
this.coreTargetScale = 0.9;
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (!this.pressed) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const cx = this.canvasWidth / 2;
|
||||||
|
const cy = this.canvasHeight / 2 - 30;
|
||||||
|
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||||
|
|
||||||
|
const dx = touch.x - cx;
|
||||||
|
const dy = touch.y - cy;
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
|
||||||
|
// Slid out of the hit box - cancel the press
|
||||||
|
if (dist > coreRadius + 40) {
|
||||||
|
this.pressed = false;
|
||||||
|
this.coreTargetScale = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchEnd() {
|
||||||
|
// Unconditionally reset the visual scale
|
||||||
|
this.coreTargetScale = 1;
|
||||||
|
// Only emit toggle when the press has not been cancelled by drag-out
|
||||||
|
if (this.pressed) {
|
||||||
|
this.$emit('toggle-play');
|
||||||
|
}
|
||||||
|
this.pressed = false;
|
||||||
|
},
|
||||||
|
onTouchCancel() {
|
||||||
|
// System interruption (incoming call, notification pull-down, etc.):
|
||||||
|
// reset visuals only — do NOT count this as a user toggle intent.
|
||||||
|
this.coreTargetScale = 1;
|
||||||
|
this.pressed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureRenderLoop() {
|
||||||
|
if (this.renderRunning) return;
|
||||||
|
this.renderRunning = true;
|
||||||
|
this.renderLoop();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLoop() {
|
||||||
|
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
|
||||||
|
const fixedDt = 0.016;
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.time += fixedDt;
|
||||||
|
} else {
|
||||||
|
this.time += fixedDt * 0.2; // slow drift when paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spring Physics for Core Scale
|
||||||
|
const tension = 120;
|
||||||
|
const friction = 12;
|
||||||
|
const force = (this.coreTargetScale - this.coreScale) * tension;
|
||||||
|
this.coreVelocity += force * fixedDt;
|
||||||
|
this.coreVelocity *= Math.exp(-friction * fixedDt);
|
||||||
|
this.coreScale += this.coreVelocity * fixedDt;
|
||||||
|
|
||||||
|
// Decay wave amplitude back to 1
|
||||||
|
if (this.waveAmplitudeMultiplier > 1) {
|
||||||
|
this.waveAmplitudeMultiplier -= fixedDt * 3;
|
||||||
|
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
|
||||||
|
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
|
||||||
|
this.waveAmplitudeMultiplier -= fixedDt * 2;
|
||||||
|
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.draw();
|
||||||
|
|
||||||
|
this.ctx.draw(false);
|
||||||
|
|
||||||
|
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
|
||||||
|
const shouldContinue =
|
||||||
|
this.isPlaying ||
|
||||||
|
this.shockwaves.length > 0 ||
|
||||||
|
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
|
||||||
|
Math.abs(this.coreVelocity) >= 0.001 ||
|
||||||
|
this.waveAmplitudeMultiplier > 0.11;
|
||||||
|
|
||||||
|
if (shouldContinue) {
|
||||||
|
// Polyfill requestAnimationFrame for uni-app
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.renderLoop();
|
||||||
|
}, 1000 / 60);
|
||||||
|
} else {
|
||||||
|
this.timer = null;
|
||||||
|
this.renderRunning = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const W = this.canvasWidth;
|
||||||
|
const H = this.canvasHeight;
|
||||||
|
|
||||||
|
// Clear entire canvas to ensure it is completely transparent and blends with the page background
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Dynamic majestic sizing to fill the huge screen area
|
||||||
|
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
|
||||||
|
const ringRadius = coreRadius + 18;
|
||||||
|
const cx = W / 2;
|
||||||
|
const cy = H / 2 - 30; // Slightly above center
|
||||||
|
|
||||||
|
// 3. Draw Shockwaves (Ripples)
|
||||||
|
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
|
||||||
|
const sw = this.shockwaves[i];
|
||||||
|
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
|
||||||
|
sw.opacity -= 0.03;
|
||||||
|
|
||||||
|
if (sw.opacity <= 0) {
|
||||||
|
this.shockwaves.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Energy Waves (Bottom area)
|
||||||
|
this.drawWaves(ctx, W, H);
|
||||||
|
|
||||||
|
// 5. Draw Interactive BPM Core
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.scale(this.coreScale, this.coreScale);
|
||||||
|
|
||||||
|
// The Core Button (Emerald Gradient)
|
||||||
|
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
|
||||||
|
|
||||||
|
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
|
||||||
|
coreGrad.addColorStop(0, '#34d399'); // Light Mint
|
||||||
|
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = coreGrad;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Clear shadow for internal elements
|
||||||
|
ctx.setShadow(0, 0, 0, 'transparent');
|
||||||
|
|
||||||
|
// Inner Subtle Highlight (Glass edge)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Text - BPM Number (MASSIVE font)
|
||||||
|
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
// Removed shadowBlur for text to save low-end GPU performance
|
||||||
|
ctx.fillText(this.bpm.toString(), 0, -12);
|
||||||
|
|
||||||
|
// Text - 'BPM' Label
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
||||||
|
ctx.font = '500 16px "Inter", sans-serif';
|
||||||
|
ctx.fillText('BPM', 0, coreRadius * 0.3);
|
||||||
|
|
||||||
|
// Play/Pause Icon
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
const iconY = coreRadius * 0.55;
|
||||||
|
if (this.isPlaying) {
|
||||||
|
// Pause Icon (Two vertical bars)
|
||||||
|
ctx.fillRect(-8, iconY - 6, 5, 14);
|
||||||
|
ctx.fillRect(3, iconY - 6, 5, 14);
|
||||||
|
} else {
|
||||||
|
// Play Icon (Triangle)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-5, iconY - 8);
|
||||||
|
ctx.lineTo(9, iconY);
|
||||||
|
ctx.lineTo(-5, iconY + 8);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
},
|
||||||
|
|
||||||
|
drawWaves(ctx, W, H) {
|
||||||
|
const baseY = H * 0.82;
|
||||||
|
const dynamicAmp = H * 0.08;
|
||||||
|
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
|
||||||
|
const waves = [
|
||||||
|
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
|
||||||
|
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeAmp = this.waveAmplitudeMultiplier;
|
||||||
|
const t = this.time;
|
||||||
|
|
||||||
|
waves.forEach(wave => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, H);
|
||||||
|
ctx.lineTo(0, baseY);
|
||||||
|
|
||||||
|
// Increased step size to drastically reduce JS-to-Native bridge commands
|
||||||
|
const step = 25;
|
||||||
|
for (let x = 0; x <= W; x += step) {
|
||||||
|
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
|
||||||
|
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add one final line to exactly W to ensure flush edge
|
||||||
|
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||||
|
ctx.lineTo(W, finalY);
|
||||||
|
|
||||||
|
ctx.lineTo(W, H);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = wave.color;
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.walker-canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walker-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent; /* Pure transparent to blend into page */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,47 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page" :style="rootStyle">
|
<view class="page" :style="rootStyle">
|
||||||
<!-- ===== 主舞台:节拍器圆 ===== -->
|
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
|
||||||
<view class="stage" @click="onCenterTap">
|
<view class="stage-canvas-wrapper">
|
||||||
<view class="ring r1" :class="{ playing: isPlaying }" />
|
<WalkerCanvas
|
||||||
<view class="ring r2" :class="{ playing: isPlaying }" />
|
ref="walkerCanvasRef"
|
||||||
<view class="core" :class="{ playing: isPlaying }">
|
:bpm="currentBpm"
|
||||||
<text class="bpm-num">{{ currentBpm }}</text>
|
:is-playing="isPlaying"
|
||||||
<text class="bpm-label">BPM</text>
|
@toggle-play="onCenterTap"
|
||||||
<view class="ctrl">
|
/>
|
||||||
<view v-if="isPlaying" class="icon-pause">
|
|
||||||
<view class="bar" />
|
|
||||||
<view class="bar" />
|
|
||||||
</view>
|
|
||||||
<view v-else class="icon-play" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
|
|
||||||
<view class="walker">
|
|
||||||
<view class="walker-box">
|
|
||||||
<video
|
|
||||||
id="runner-video"
|
|
||||||
class="runner-video"
|
|
||||||
:src="RUNNER_VIDEO_URL"
|
|
||||||
:muted="true"
|
|
||||||
:loop="true"
|
|
||||||
:autoplay="true"
|
|
||||||
:show-controls="false"
|
|
||||||
:show-fullscreen-btn="false"
|
|
||||||
:show-play-btn="false"
|
|
||||||
:show-center-play-btn="false"
|
|
||||||
:enable-progress-gesture="false"
|
|
||||||
:show-mute-btn="false"
|
|
||||||
:picture-in-picture-mode="[]"
|
|
||||||
object-fit="contain"
|
|
||||||
@error="onVideoError"
|
|
||||||
@loadedmetadata="onVideoLoaded"
|
|
||||||
/>
|
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
|
||||||
<cover-view class="pip-mask"> </cover-view>
|
|
||||||
<!-- #endif -->
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||||
@@ -50,276 +16,168 @@
|
|||||||
v-for="p in LOOP_PRESETS"
|
v-for="p in LOOP_PRESETS"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
class="preset"
|
class="preset"
|
||||||
:class="{ active: mode === 'preset' && currentLoop === p.id }"
|
:class="{ active: currentLoop === p.id }"
|
||||||
@click="onPresetTap(p.id)"
|
@click="onPresetTap(p.id)"
|
||||||
>
|
>
|
||||||
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
|
<text class="preset-name">{{ p.label }}</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">{{ presetDesc(p.id) }}</text>
|
<text class="preset-desc">{{ p.desc }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ===== 自定义节奏(高级折叠区) ===== -->
|
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
|
||||||
<view v-if="customExpanded" class="custom-panel">
|
<view class="custom-section">
|
||||||
<view class="custom-warn">
|
<!-- 标题栏(始终显示) -->
|
||||||
<text class="warn-dot">●</text>
|
<view class="custom-header" @click="onToggleCustom">
|
||||||
<text class="warn-text">前台模式 · 切后台会暂停</text>
|
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- BPM 微调 -->
|
<!-- 折叠内容(仅展开时显示) -->
|
||||||
<view class="row">
|
<view v-if="customExpanded" class="custom-content">
|
||||||
<text class="row-label">BPM</text>
|
<!-- BPM 微调 -->
|
||||||
<view class="bpm-stepper">
|
<view class="row">
|
||||||
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
<text class="row-label">BPM</text>
|
||||||
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
<view class="bpm-stepper">
|
||||||
<view class="step-val">{{ customBpm }}</view>
|
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||||
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||||
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
<view class="step-val">{{ currentBpm }}</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 拍号 -->
|
||||||
|
<view class="row">
|
||||||
|
<text class="row-label">拍号</text>
|
||||||
|
<view class="meter-tabs">
|
||||||
|
<view
|
||||||
|
v-for="n in [2, 3, 4]"
|
||||||
|
:key="n"
|
||||||
|
class="meter-tab"
|
||||||
|
:class="{ active: customAccent === n }"
|
||||||
|
@click="onAccentSelect(n)"
|
||||||
|
>{{ n }}/4</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 拍号 -->
|
|
||||||
<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>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } 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'
|
||||||
|
import WalkerCanvas from './components/walker-canvas.vue'
|
||||||
|
|
||||||
/**
|
type LoopId = 'slow' | 'normal' | 'brisk'
|
||||||
* 跑步动画视频(腾讯云 COS CDN)
|
interface LoopPreset {
|
||||||
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
|
id: LoopId
|
||||||
* 否则正式发布版会被拦截(开发版默认不校验)
|
bpm: number
|
||||||
*/
|
label: string
|
||||||
const RUNNER_VIDEO_URL =
|
desc: string
|
||||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
}
|
||||||
|
|
||||||
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||||
const VIDEO_BASE_BPM = 124
|
{ id: 'slow', bpm: 80, label: '慢走', desc: '热身 · 恢复' },
|
||||||
|
{ id: 'normal', bpm: 110, label: '健走', desc: '日常 · 通勤' },
|
||||||
|
{ id: 'brisk', bpm: 130, label: '快走', desc: '提速 · 燃脂' },
|
||||||
|
]
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 双引擎设计
|
|
||||||
* ------------------------------------------------------------
|
|
||||||
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
|
|
||||||
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
|
|
||||||
* - 切模式时停掉对侧引擎,避免双声道叠加
|
|
||||||
* ============================================================ */
|
|
||||||
type Mode = 'preset' | 'custom'
|
|
||||||
const mode = ref<Mode>('preset')
|
|
||||||
const customExpanded = ref<boolean>(false)
|
const customExpanded = ref<boolean>(false)
|
||||||
|
const currentLoop = ref<LoopId | null>('normal')
|
||||||
|
const walkerCanvasRef = ref<any>(null)
|
||||||
|
|
||||||
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
|
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
|
||||||
const CLICK_WOOD_URL =
|
const CLICK_WOOD_URL =
|
||||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
|
||||||
|
|
||||||
const bg = useMetronomeBg()
|
// 统一采用高精度引擎,彻底抛弃卡顿的后台播放引擎
|
||||||
const fg = useMetronome({
|
const fg = useMetronome({
|
||||||
initialBpm: 100,
|
initialBpm: 110,
|
||||||
accentEvery: 2,
|
accentEvery: 2,
|
||||||
clickSrc: CLICK_WOOD_URL,
|
clickSrc: CLICK_WOOD_URL,
|
||||||
accentSrc: CLICK_WOOD_URL,
|
accentSrc: CLICK_WOOD_URL,
|
||||||
poolSize: 4,
|
poolSize: 4,
|
||||||
})
|
// 【完美音画同步核心】
|
||||||
|
onBeat: (index, isAccent) => {
|
||||||
const { LOOP_PRESETS } = bg
|
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
|
||||||
const currentLoop = bg.currentLoop
|
walkerCanvasRef.value.triggerBeat()
|
||||||
|
}
|
||||||
const customBpm = computed(() => fg.bpm.value)
|
|
||||||
const customAccent = computed(() => fg.accentEvery.value)
|
|
||||||
|
|
||||||
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
|
|
||||||
const isPlaying = computed(() =>
|
|
||||||
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 isPlaying = computed(() => fg.isPlaying.value)
|
||||||
|
const currentBpm = computed(() => fg.bpm.value)
|
||||||
|
const customAccent = computed(() => fg.accentEvery.value)
|
||||||
|
const intervalMs = computed(() => 60000 / fg.bpm.value)
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 档位元数据
|
* 中央圆按钮
|
||||||
* ============================================================ */
|
|
||||||
const 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 = () => {
|
const onCenterTap = () => {
|
||||||
if (mode.value === 'preset') {
|
if (fg.isPlaying.value) {
|
||||||
if (!currentLoop.value) {
|
fg.stop()
|
||||||
bg.playLoop('normal')
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (bg.isPlaying.value) {
|
|
||||||
bg.pause()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
} else {
|
|
||||||
bg.resume()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
/* custom 模式 */
|
fg.start()
|
||||||
if (fg.isPlaying.value) {
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
fg.stop()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
} else {
|
|
||||||
fg.start()
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
|
* 档位卡片
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
const onPresetTap = (id: LoopId) => {
|
const onPresetTap = (id: LoopId) => {
|
||||||
if (mode.value === 'custom') {
|
const preset = LOOP_PRESETS.find(p => p.id === id)
|
||||||
fg.stop()
|
if (!preset) return
|
||||||
mode.value = 'preset'
|
|
||||||
customExpanded.value = false
|
currentLoop.value = id
|
||||||
|
fg.setBpm(preset.bpm)
|
||||||
|
if (!fg.isPlaying.value) {
|
||||||
|
fg.start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
}
|
}
|
||||||
bg.playLoop(id)
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 自定义折叠区切换
|
* 自定义折叠区切换
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
const onToggleCustom = () => {
|
const onToggleCustom = () => {
|
||||||
if (!customExpanded.value) {
|
customExpanded.value = !customExpanded.value
|
||||||
/* 展开:进入 custom 模式 + 停掉 preset */
|
if (customExpanded.value) {
|
||||||
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()
|
fg.preload()
|
||||||
customExpanded.value = true
|
|
||||||
} else {
|
} else {
|
||||||
/* 收起:回到 preset,停掉 fg */
|
// 收起时,如果当前 BPM 刚好匹配某个档位,高亮对应档位卡片
|
||||||
fg.stop()
|
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
|
||||||
mode.value = 'preset'
|
currentLoop.value = matched ? matched.id : null
|
||||||
customExpanded.value = false
|
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBpmDelta = (delta: number) => {
|
const onBpmDelta = (delta: number) => {
|
||||||
fg.setBpm(fg.bpm.value + delta)
|
fg.setBpm(fg.bpm.value + delta)
|
||||||
|
currentLoop.value = null // 用户手动微调后取消档位高亮
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAccentSelect = (n: number) => {
|
const onAccentSelect = (n: number) => {
|
||||||
fg.setAccentEvery(n)
|
fg.setAccentEvery(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 视频实例与 BPM 同步
|
|
||||||
* ============================================================ */
|
|
||||||
let videoCtx: UniApp.VideoContext | null = null
|
|
||||||
|
|
||||||
const playbackRate = computed(() => {
|
|
||||||
const rate = currentBpm.value / VIDEO_BASE_BPM
|
|
||||||
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
videoCtx = uni.createVideoContext('runner-video')
|
|
||||||
setTimeout(() => videoCtx?.pause(), 50)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onVideoError = (e: any) => {
|
|
||||||
console.error('[runner-video] error:', e?.detail || e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onVideoLoaded = (e: any) => {
|
|
||||||
console.log('[runner-video] metadata loaded:', e?.detail)
|
|
||||||
try {
|
|
||||||
videoCtx?.playbackRate?.(playbackRate.value)
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isPlaying, (playing) => {
|
|
||||||
if (playing) {
|
|
||||||
videoCtx?.play()
|
|
||||||
} else {
|
|
||||||
videoCtx?.pause()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(playbackRate, (rate) => {
|
|
||||||
try {
|
|
||||||
videoCtx?.playbackRate?.(rate)
|
|
||||||
} catch (_) {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const rootStyle = computed(() => ({
|
const rootStyle = computed(() => ({
|
||||||
'--beat-duration': `${intervalMs.value}ms`,
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
|
fg.preload()
|
||||||
if (mode.value === 'custom') {
|
|
||||||
fg.preload()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* onHide:差异化处理
|
|
||||||
* - preset 模式: 不停,BgAudio 接管后台播放
|
|
||||||
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
|
|
||||||
*/
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
if (mode.value === 'custom') {
|
fg.stop()
|
||||||
fg.stop()
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
/* 页面销毁时全部清理 */
|
fg.stop()
|
||||||
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>
|
||||||
@@ -359,166 +217,19 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 节拍器主舞台
|
* 统一运动律动视区 (Canvas)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.stage {
|
.stage-canvas-wrapper {
|
||||||
position: relative;
|
width: 100vw;
|
||||||
width: 480rpx;
|
margin-left: -28rpx;
|
||||||
height: 480rpx;
|
margin-right: -28rpx;
|
||||||
margin-top: 12rpx;
|
flex-shrink: 0; /* 防止被压缩 */
|
||||||
display: flex;
|
height: 600rpx; /* 固定高度,不随内容变化 */
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active .core {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ring {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 3rpx solid rgba(16, 185, 129, 0.12);
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
&.playing {
|
|
||||||
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
|
||||||
}
|
|
||||||
&.r2.playing {
|
|
||||||
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes ring-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.94);
|
|
||||||
opacity: 0.85;
|
|
||||||
border-color: rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1.18);
|
|
||||||
opacity: 0;
|
|
||||||
border-color: rgba(16, 185, 129, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.core {
|
|
||||||
position: relative;
|
|
||||||
width: 380rpx;
|
|
||||||
height: 380rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
|
|
||||||
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
|
|
||||||
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
|
|
||||||
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
|
|
||||||
|
|
||||||
&.playing {
|
|
||||||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bpm-num {
|
|
||||||
font-size: 168rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -2rpx;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bpm-label {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
letter-spacing: 6rpx;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctrl {
|
|
||||||
margin-top: 22rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes core-beat {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 播放/暂停 icon */
|
|
||||||
.icon-play {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-left: 52rpx solid #fff;
|
|
||||||
border-top: 32rpx solid transparent;
|
|
||||||
border-bottom: 32rpx solid transparent;
|
|
||||||
margin-left: 12rpx;
|
|
||||||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
|
||||||
}
|
|
||||||
.icon-pause {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
width: 16rpx;
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4rpx;
|
|
||||||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 跑步小人
|
|
||||||
* ============================================================ */
|
|
||||||
.walker {
|
|
||||||
width: 100%;
|
|
||||||
height: 240rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
.walker-box {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 240rpx;
|
|
||||||
height: 240rpx;
|
|
||||||
background: #e8ecf2;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.runner-video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #e8ecf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pip-mask {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 80rpx;
|
|
||||||
height: 56rpx;
|
|
||||||
background-color: #dde2eb;
|
|
||||||
border-bottom-left-radius: 18rpx;
|
|
||||||
border-top-right-radius: 24rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -535,19 +246,14 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
|||||||
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: 18rpx 8rpx 16rpx;
|
padding: 26rpx 8rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4rpx;
|
gap: 8rpx;
|
||||||
box-shadow: $shadow-sm;
|
box-shadow: $shadow-sm;
|
||||||
transition: all 0.18s;
|
transition: all 0.18s;
|
||||||
|
|
||||||
.preset-icon {
|
|
||||||
font-size: 40rpx;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 4rpx;
|
|
||||||
}
|
|
||||||
.preset-name {
|
.preset-name {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -591,9 +297,32 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 自定义节奏面板(仅 customExpanded 时显示)
|
* 自定义节奏区域(标题栏 + 折叠内容)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.custom-panel {
|
.custom-section {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16rpx 6rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.custom-title {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: $card-bg;
|
background: $card-bg;
|
||||||
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
||||||
@@ -603,6 +332,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-warn {
|
.custom-warn {
|
||||||
@@ -705,7 +435,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 底部行(自定义入口 + 锁屏提示,横向排布)
|
* 底部行(已废弃,保留样式以防引用)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.bottom-row {
|
.bottom-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user