主包超出 2MB 上限无法上传发布版,把训练相关全部移到独立分包。
目录调整:
- pages/training/* → training/pages/*
- hooks/use{Metronome,VoiceCoach,TrainingSession,TrainingBgm}.ts
→ training/hooks/*
- static/training/* → training/static/*
- 主包 hooks/ 目录已清空,所有 use* 都被分包独占
引用路径更新:
- 分包内 hook import 用相对路径 ../hooks/xxx,不依赖 @/ alias
- 资源路径 /static/training/audio/click.mp3
→ /training/static/audio/click.mp3
- useMetronome 默认 clickSrc / useVoiceCoach VOICE_BASE
/ useTrainingBgm BGM_BASE 全部对齐新路径
pages.json:
- 主包 pages 移除 training/index 和 training/metronome
- subPackages 新增 root="training",含 pages/index 和 pages/metronome
- 跟现有 TUIKit / doctor 分包风格一致
调用方:
- components/dev-training-entry navigateTo URL 更新成
/training/pages/metronome (此组件留在主包,主包跳分包是合法操作)
文档/工具同步:
- training/static/README.md 路径示例改为 training/static/...
- scripts/generate-voice.mjs VOICE_DIR 与提示输出对齐新路径
Git mv 检测到的全是 rename(R),历史完整保留。
分包总体 80K(pages 44K + hooks 20K + static 16K),主包瘦身明显。
Co-authored-by: Cursor <cursoragent@cursor.com>
116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
import { ref, onUnmounted } from 'vue'
|
|
|
|
const BGM_BASE = '/training/static/bgm'
|
|
|
|
export type BgmMode = 'none' | 'training' | 'resting'
|
|
|
|
export interface UseTrainingBgmOptions {
|
|
trainingSrc?: string
|
|
restingSrc?: string
|
|
volume?: number
|
|
fadeMs?: number
|
|
}
|
|
|
|
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
|
|
const {
|
|
trainingSrc = `${BGM_BASE}/train-light.mp3`,
|
|
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
|
|
volume = 0.6,
|
|
fadeMs = 600,
|
|
} = options
|
|
|
|
const mode = ref<BgmMode>('none')
|
|
const enabled = ref<boolean>(true)
|
|
|
|
let trainCtx: UniApp.InnerAudioContext | null = null
|
|
let restCtx: UniApp.InnerAudioContext | null = null
|
|
let fadeTimers: ReturnType<typeof setInterval>[] = []
|
|
|
|
const ensureCtx = () => {
|
|
if (!trainCtx) {
|
|
trainCtx = uni.createInnerAudioContext()
|
|
trainCtx.src = trainingSrc
|
|
trainCtx.loop = true
|
|
trainCtx.obeyMuteSwitch = false
|
|
trainCtx.volume = 0
|
|
}
|
|
if (!restCtx) {
|
|
restCtx = uni.createInnerAudioContext()
|
|
restCtx.src = restingSrc
|
|
restCtx.loop = true
|
|
restCtx.obeyMuteSwitch = false
|
|
restCtx.volume = 0
|
|
}
|
|
}
|
|
|
|
const fade = (
|
|
ctx: UniApp.InnerAudioContext,
|
|
from: number,
|
|
to: number,
|
|
duration = fadeMs,
|
|
) => {
|
|
const steps = 16
|
|
const stepMs = Math.max(16, duration / steps)
|
|
let i = 0
|
|
const t = setInterval(() => {
|
|
i++
|
|
const v = from + (to - from) * (i / steps)
|
|
ctx.volume = Math.max(0, Math.min(1, v))
|
|
if (i >= steps) {
|
|
clearInterval(t)
|
|
fadeTimers = fadeTimers.filter((x) => x !== t)
|
|
}
|
|
}, stepMs)
|
|
fadeTimers.push(t)
|
|
}
|
|
|
|
const switchTo = (target: BgmMode) => {
|
|
if (!enabled.value) return
|
|
if (mode.value === target) return
|
|
ensureCtx()
|
|
|
|
if (target === 'training' && trainCtx && restCtx) {
|
|
fade(restCtx, restCtx.volume, 0)
|
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
|
trainCtx.play()
|
|
fade(trainCtx, 0, volume)
|
|
} else if (target === 'resting' && trainCtx && restCtx) {
|
|
fade(trainCtx, trainCtx.volume, 0)
|
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
|
restCtx.play()
|
|
fade(restCtx, 0, volume)
|
|
} else if (target === 'none') {
|
|
if (trainCtx) {
|
|
fade(trainCtx, trainCtx.volume, 0)
|
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
|
}
|
|
if (restCtx) {
|
|
fade(restCtx, restCtx.volume, 0)
|
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
|
}
|
|
}
|
|
mode.value = target
|
|
}
|
|
|
|
const setEnabled = (v: boolean) => {
|
|
enabled.value = v
|
|
if (!v) switchTo('none')
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
fadeTimers.forEach((t) => clearInterval(t))
|
|
fadeTimers = []
|
|
trainCtx?.destroy?.()
|
|
restCtx?.destroy?.()
|
|
trainCtx = null
|
|
restCtx = null
|
|
})
|
|
|
|
return {
|
|
mode,
|
|
enabled,
|
|
switchTo,
|
|
setEnabled,
|
|
}
|
|
}
|