feat: v1.5 — plan editor, voice prompts, UI polish
Major features: - Training plan editor: edit preset days/duration per plan (in-place override via custom_plans storage; preset ids preserved so existing records stay valid) - Voice prompts at 4 fixed points during training (halfway / 30s / 10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js) - Free-training button: switched from outline (transparent) to ghost variant (theme-tinted) for visual weight Bug fixes: - 4 functional pages: SVG data URIs failed to render in WeChat because '\#' in colors was parsed as a data-URI fragment delimiter. encode the whole SVG via encodeURIComponent in utils/icons.js build(). - Old records (saved before id field was added) could not be deleted (data-id was empty, triggered defensive guard). Backfill stable legacy-<month>-<index>-<duration> ids in getRecords() and persist. - settings.js plan-row editor button event was bubbling up to the row's bindtap (which also fired onSelectPlan). Wrapped in catch:tap. UI: - Settings page: 3 hardcoded plans replaced with dynamic buildPlansList that overlays custom_plans on top of presets - Plan editor: bottom-sheet modal in settings page (regular view, not ui-modal — WeChat custom component root element drops position:fixed in this runtime) - Free-training button: rounded pill style, full-width, 24rpx gap from primary action; bottom sheet uses max-height: 88vh + internal scroll - Version bumped to v1.5, last updated 2026-06-10 Removed: - Daily reminder section (dailyReminder / reminderTime) — replaced by the 4 voice prompts which cover the same user need without requiring long-term scheduling that WeChat mini-programs can't actually do Misc: - utils/plan.js refactored to formula-driven: presets declare totalDays/startTarget/increment/cycleDays, days[] is generated. Same formula applies to custom plans. - Timer _remind() guards each prompt on minimum duration so short free-mode sessions don't fire 'last30' at the start - Cloud storage cloud_plans field added to data push payload; restored on first install via _restoreFromCloud - .gitignore added for local AI tool caches (.reasonix/, reasonix.toml, .codegraph/daemon.pid)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Voice prompt utility for the timer.
|
||||
*
|
||||
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
|
||||
* cloud function with a key, get back an audio URL (cached in cloud
|
||||
* storage after first synthesis), and play it via createInnerAudioContext.
|
||||
*
|
||||
* The in-memory `_urlCache` avoids hitting the cloud function more than
|
||||
* once per prompt per session. The audio context is a singleton so
|
||||
* overlapping prompts interrupt each other cleanly.
|
||||
*
|
||||
* If the cloud function is unconfigured (no TTS credentials) and returns
|
||||
* `success: false`, we fall back to a silent no-op — better than spamming
|
||||
* the user with toast errors mid-plank.
|
||||
*/
|
||||
|
||||
const PROMPTS = {
|
||||
halfway: '已完成一半啦,坚持就是胜利!',
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
}
|
||||
|
||||
let _ctx = null
|
||||
const _urlCache = {} // { promptKey: audioUrl }
|
||||
|
||||
const _getCtx = () => {
|
||||
if (!_ctx) {
|
||||
_ctx = wx.createInnerAudioContext()
|
||||
// Play even when the device is in silent mode — we want the user to
|
||||
// actually hear training cues during a session.
|
||||
_ctx.obeyMuteSwitch = false
|
||||
}
|
||||
return _ctx
|
||||
}
|
||||
|
||||
const _fetchUrl = async (promptKey) => {
|
||||
if (_urlCache[promptKey]) return _urlCache[promptKey]
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'tts',
|
||||
data: { promptKey },
|
||||
// Default WeChat cloud timeout is 3s, which is exactly the TTS
|
||||
// first-call budget (synth + upload + tempURL). Bump to 30s for
|
||||
// cold starts; cached calls return in <500ms.
|
||||
config: { timeout: 30000 }
|
||||
})
|
||||
if (res && res.result && res.result.success && res.result.audioUrl) {
|
||||
_urlCache[promptKey] = res.result.audioUrl
|
||||
return res.result.audioUrl
|
||||
}
|
||||
} catch (e) {
|
||||
// cloud function not deployed or other error — silent fallback
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a prompt. Returns a Promise that resolves when playback starts
|
||||
* (or immediately if there's no audio available).
|
||||
*/
|
||||
const play = async (promptKey) => {
|
||||
if (!PROMPTS[promptKey]) return
|
||||
const url = await _fetchUrl(promptKey)
|
||||
if (!url) return
|
||||
const ctx = _getCtx()
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.src = url
|
||||
ctx.play()
|
||||
} catch (e) { /* ignore playback errors */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any currently-playing voice prompt. Call this when the user
|
||||
* pauses/stops/finishes training to avoid stray audio.
|
||||
*/
|
||||
const stop = () => {
|
||||
if (_ctx) {
|
||||
try { _ctx.stop() } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the audio context. Call on page unload.
|
||||
*/
|
||||
const destroy = () => {
|
||||
if (_ctx) {
|
||||
try { _ctx.destroy() } catch (e) { /* ignore */ }
|
||||
_ctx = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { play, stop, destroy, PROMPTS }
|
||||
Reference in New Issue
Block a user