diff --git a/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts b/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts deleted file mode 100644 index 48682ddf..00000000 --- a/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts +++ /dev/null @@ -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(false) - const currentLoop = ref(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, - } -} diff --git a/TUICallKit-Vue3/training/pages/components/walker-canvas.vue b/TUICallKit-Vue3/training/pages/components/walker-canvas.vue new file mode 100644 index 00000000..ce94a184 --- /dev/null +++ b/TUICallKit-Vue3/training/pages/components/walker-canvas.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/TUICallKit-Vue3/training/pages/metronome.vue b/TUICallKit-Vue3/training/pages/metronome.vue index e62df55e..0feaead4 100644 --- a/TUICallKit-Vue3/training/pages/metronome.vue +++ b/TUICallKit-Vue3/training/pages/metronome.vue @@ -1,47 +1,13 @@ @@ -359,166 +217,19 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); } /* ============================================================ - * 节拍器主舞台 + * 统一运动律动视区 (Canvas) * ============================================================ */ -.stage { - position: relative; - width: 480rpx; - height: 480rpx; - margin-top: 12rpx; - display: flex; - 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; +.stage-canvas-wrapper { + width: 100vw; + margin-left: -28rpx; + margin-right: -28rpx; + flex-shrink: 0; /* 防止被压缩 */ + height: 600rpx; /* 固定高度,不随内容变化 */ display: flex; justify-content: center; align-items: center; -} - -.walker-box { + margin-bottom: 24rpx; 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; border: 2rpx solid $card-border; border-radius: $radius-card; - padding: 18rpx 8rpx 16rpx; + padding: 26rpx 8rpx; display: flex; flex-direction: column; align-items: center; - gap: 4rpx; + gap: 8rpx; box-shadow: $shadow-sm; transition: all 0.18s; - .preset-icon { - font-size: 40rpx; - line-height: 1; - margin-bottom: 4rpx; - } .preset-name { font-size: 28rpx; 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%; background: $card-bg; 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; flex-direction: column; gap: 16rpx; + margin-top: 8rpx; } .custom-warn { @@ -705,7 +435,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); } /* ============================================================ - * 底部行(自定义入口 + 锁屏提示,横向排布) + * 底部行(已废弃,保留样式以防引用) * ============================================================ */ .bottom-row { width: 100%;