const audioPages = require('../../data/audioPages') const SEEK_TIMEOUT_MS = 1600 const PAUSE_LOCK_TIMEOUT_MS = 1200 const RATE_VALUES = Object.freeze([0.8, 1, 1.2]) const RATE_OPTIONS = Object.freeze([ Object.freeze({ value: 0.8, label: '0.8倍' }), Object.freeze({ value: 1, label: '1.0倍' }), Object.freeze({ value: 1.2, label: '1.2倍' }), ]) const FALLBACK_RATE_OPTIONS = Object.freeze([ Object.freeze({ value: 1, label: '1.0倍' }), ]) function formatTime(value) { const seconds = Math.max(0, Math.floor(Number(value) || 0)) return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` } function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, Number(value) || 0)) } function eventValue(event) { return Number(event && event.detail && event.detail.value) || 0 } Page({ data: { pageId: '', pageNumber: 1, title: '', caption: '', imageSrc: '', playing: false, loading: false, audioReady: false, hasStarted: false, seekLocked: false, error: '', currentLabel: '0:00', durationLabel: '0:00', durationSeconds: 1, sliderValue: 0, progressStyle: 'width:0%', playbackRate: 1, rateOptions: FALLBACK_RATE_OPTIONS, rateControlsVisible: false, reviewStatus: 'technical-qa-pass-human-listening-pending', }, onLoad(options = {}) { this._unloaded = false this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1 this._contextGeneration = 0 this._intentGeneration = 0 this._seekOperationGeneration = 0 this._controlOperationGeneration = 0 this._desiredPlayback = false this._activePlayIntentGeneration = 0 this._controlLocked = false this._scrubbing = false this._pendingSeek = null this._seekTimer = null this._pendingPauseLock = null this._pauseLockTimer = null this._page = null this.bindAudioInterruptionHandlers() const pageId = String(options.pageId || '') const page = audioPages[pageId] if (!page) { this.setData({ error: '这一页的声音没有找到,请返回连环画。' }) return } this._page = page const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1) this.setData({ pageId, pageNumber: page.pageNumber, title: page.title, caption: page.caption, imageSrc: page.imageSrc, durationLabel: formatTime(durationSeconds), durationSeconds, }) }, onReady() { if (!this._page || this._unloaded) return this.createAudioContext(this._page.audioSrc) }, isCurrentContext(context, generation) { return !this._unloaded && this.audioContext === context && this._contextGeneration === generation }, setProgress(value, durationValue) { const duration = Math.max( 1, Number(durationValue) || Number(this.data.durationSeconds) || 1, ) const current = clamp(value, 0, duration) const percent = Math.min(100, current / duration * 100) this.setData({ currentLabel: formatTime(current), durationLabel: formatTime(duration), durationSeconds: duration, sliderValue: current, progressStyle: `width:${percent}%`, }) }, disableRateControls(context) { try { if (context && typeof context.playbackRate === 'number') { context.playbackRate = 1 } } catch (_) { // A runtime that rejects playbackRate remains safely at normal speed. } this.setData({ playbackRate: 1, rateOptions: FALLBACK_RATE_OPTIONS, rateControlsVisible: false, }) }, configureRateControls(context, generation) { try { if (typeof context.playbackRate !== 'number') { this.disableRateControls(context) return } for (const rate of RATE_VALUES) { context.playbackRate = rate if (Math.abs(Number(context.playbackRate) - rate) > 0.001) { this.disableRateControls(context) return } } context.playbackRate = 1 if ( !this.isCurrentContext(context, generation) || Math.abs(Number(context.playbackRate) - 1) > 0.001 ) { this.disableRateControls(context) return } this.setData({ playbackRate: 1, rateOptions: RATE_OPTIONS, rateControlsVisible: true, }) } catch (_) { this.disableRateControls(context) } }, createAudioContext(src) { if (this._unloaded) return null if (this.audioContext) this.destroyAudioContext() const context = wx.createInnerAudioContext() const generation = this._contextGeneration + 1 this._contextGeneration = generation this.audioContext = context context.autoplay = false context.src = src this.setData({ audioReady: true, loading: false, playing: false, error: '', }) this.configureRateControls(context, generation) context.onCanplay(() => { if (!this.isCurrentContext(context, generation)) return this.setData({ audioReady: true, error: '' }) }) context.onPlay(() => { if (!this.isCurrentContext(context, generation)) return if ( !this._desiredPlayback || this._activePlayIntentGeneration !== this._intentGeneration ) { context.pause() this._controlLocked = false this.setData({ playing: false, loading: false }) return } this._controlLocked = false this.setData({ playing: true, loading: false, hasStarted: true, error: '', }) }) context.onPause(() => { if (!this.isCurrentContext(context, generation)) return if (this._desiredPlayback) return this.releasePauseLockFromEvent(context, generation) this.setData({ playing: false, loading: false }) }) context.onStop(() => { if (!this.isCurrentContext(context, generation)) return this.invalidatePlaybackIntent(false) this.setData({ playing: false, loading: false }) }) context.onWaiting(() => { if ( !this.isCurrentContext(context, generation) || !this._desiredPlayback ) return this.setData({ playing: false, loading: true }) }) context.onEnded(() => { if (!this.isCurrentContext(context, generation)) return this.invalidatePlaybackIntent(false) this.setProgress(this.data.durationSeconds, this.data.durationSeconds) this.setData({ playing: false, loading: false }) }) context.onTimeUpdate(() => { if ( !this.isCurrentContext(context, generation) || this._scrubbing || this._pendingSeek ) return const duration = Number(context.duration) || this.data.durationSeconds const current = Number(context.currentTime) || 0 this.setProgress(current, duration) }) if (typeof context.onSeeked === 'function') { context.onSeeked(() => { if (!this.isCurrentContext(context, generation)) return this.finishSeekFromEvent(context, generation) }) } context.onError(() => { if (!this.isCurrentContext(context, generation)) return this.invalidatePlaybackIntent(false) this.setData({ playing: false, loading: false, error: '声音暂时没打开,您可以返回继续看连环画。', }) }) this.bindAudioInterruptionHandlers() return context }, beginPlaybackIntent() { this._intentGeneration += 1 this._desiredPlayback = true this._activePlayIntentGeneration = this._intentGeneration return this._intentGeneration }, clearSeekState(updateData = true) { if (this._seekTimer !== null) { clearTimeout(this._seekTimer) this._seekTimer = null } this._seekOperationGeneration += 1 this._pendingSeek = null this._scrubbing = false if (updateData && !this._unloaded) this.setData({ seekLocked: false }) }, clearPauseLock(unlock = true) { if (this._pauseLockTimer !== null) { clearTimeout(this._pauseLockTimer) this._pauseLockTimer = null } this._controlOperationGeneration += 1 this._pendingPauseLock = null if (unlock) this._controlLocked = false }, beginPauseLock(context) { this.clearPauseLock(true) const operationGeneration = this._controlOperationGeneration + 1 this._controlOperationGeneration = operationGeneration const pending = { operationGeneration, contextGeneration: this._contextGeneration, intentGeneration: this._intentGeneration, lifecycleGeneration: this._lifecycleGeneration, context, } this._pendingPauseLock = pending this._controlLocked = true this._pauseLockTimer = setTimeout(() => { this.releasePauseLock( operationGeneration, pending.contextGeneration, pending.intentGeneration, pending.lifecycleGeneration, context, ) }, PAUSE_LOCK_TIMEOUT_MS) }, releasePauseLock( operationGeneration, contextGeneration, intentGeneration, lifecycleGeneration, context, ) { const pending = this._pendingPauseLock if ( !pending || pending.operationGeneration !== operationGeneration || pending.contextGeneration !== contextGeneration || pending.intentGeneration !== intentGeneration || pending.lifecycleGeneration !== lifecycleGeneration || pending.context !== context || !this.isCurrentContext(context, contextGeneration) || this._intentGeneration !== intentGeneration || this._lifecycleGeneration !== lifecycleGeneration || this._desiredPlayback ) return false if (this._pauseLockTimer !== null) { clearTimeout(this._pauseLockTimer) this._pauseLockTimer = null } this._pendingPauseLock = null this._controlLocked = false return true }, releasePauseLockFromEvent(context, contextGeneration) { const pending = this._pendingPauseLock if (!pending) { if (this.isCurrentContext(context, contextGeneration)) { this._controlLocked = false } return } this.releasePauseLock( pending.operationGeneration, contextGeneration, pending.intentGeneration, pending.lifecycleGeneration, context, ) }, invalidatePlaybackIntent(pauseContext = true) { this._intentGeneration += 1 this._activePlayIntentGeneration = 0 this._desiredPlayback = false this.clearPauseLock(true) this.clearSeekState(!this._unloaded) if ( pauseContext && this.audioContext && typeof this.audioContext.pause === 'function' ) { this.audioContext.pause() } }, startPlaybackWithIntent(intentGeneration) { const context = this.audioContext if ( !context || this._unloaded || intentGeneration !== this._intentGeneration || !this._desiredPlayback ) return false this._controlLocked = true this.setData({ loading: true, playing: false, hasStarted: true, error: '', }) context.play() return true }, bindAudioInterruptionHandlers() { if (this._audioInterruptionBeginHandler) return const generation = this._lifecycleGeneration this._audioInterruptionBeginHandler = () => { if (this._unloaded || this._lifecycleGeneration !== generation) return this.pauseWithoutAutoResume() } this._audioInterruptionEndHandler = () => { if (this._unloaded || this._lifecycleGeneration !== generation) return this.invalidatePlaybackIntent(false) this.setData({ playing: false, loading: false }) } if (typeof wx.onAudioInterruptionBegin === 'function') { wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler) } if (typeof wx.onAudioInterruptionEnd === 'function') { wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler) } }, unbindAudioInterruptionHandlers() { if ( this._audioInterruptionBeginHandler && typeof wx.offAudioInterruptionBegin === 'function' ) { wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler) } if ( this._audioInterruptionEndHandler && typeof wx.offAudioInterruptionEnd === 'function' ) { wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler) } this._audioInterruptionBeginHandler = null this._audioInterruptionEndHandler = null }, pauseWithoutAutoResume() { const context = this.audioContext this.invalidatePlaybackIntent(false) if (context && typeof context.pause === 'function') { this.beginPauseLock(context) try { context.pause() } catch (_) { this.clearPauseLock(true) } } if (!this._unloaded) { this.setData({ playing: false, loading: false }) } }, onHide() { this.pauseWithoutAutoResume() }, onShow() { if (this._unloaded) return this.clearPauseLock(true) this.setData({ playing: false, loading: false, seekLocked: false }) }, togglePlayback() { if (this.data.loading || this._controlLocked || this._unloaded) return if (!this.audioContext && this._page) { this.createAudioContext(this._page.audioSrc) } if (!this.audioContext) return if (this.data.playing || this._desiredPlayback) { this.pauseWithoutAutoResume() return } const intentGeneration = this.beginPlaybackIntent() this.startPlaybackWithIntent(intentGeneration) }, requestSeek(targetValue, options = {}) { const context = this.audioContext if ( !context || this._unloaded || this.data.loading || this._pendingSeek ) return false const duration = Math.max( 1, Number(context.duration) || Number(this.data.durationSeconds) || 1, ) const target = clamp(targetValue, 0, duration) const actualBeforeSeek = Number(context.currentTime) const previousValue = clamp( Number.isFinite(actualBeforeSeek) ? actualBeforeSeek : Number(this.data.sliderValue) || 0, 0, duration, ) const operationGeneration = this._seekOperationGeneration + 1 this._seekOperationGeneration = operationGeneration const pending = { operationGeneration, contextGeneration: this._contextGeneration, intentGeneration: this._intentGeneration, context, target, previousValue, playAfterSeek: Boolean(options.playAfterSeek), } this._pendingSeek = pending this._scrubbing = false this.setProgress(target, duration) this.setData({ seekLocked: true, error: '' }) this._seekTimer = setTimeout(() => { this.failSeek( operationGeneration, pending.contextGeneration, pending.intentGeneration, context, ) }, SEEK_TIMEOUT_MS) try { context.seek(target) } catch (_) { this.failSeek( operationGeneration, pending.contextGeneration, pending.intentGeneration, context, ) return false } return true }, finishSeekFromEvent(context, contextGeneration) { const pending = this._pendingSeek if (!pending) return const actual = Number(context.currentTime) if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return this.finishSeek( pending.operationGeneration, contextGeneration, pending.intentGeneration, context, ) }, finishSeek( operationGeneration, contextGeneration, intentGeneration, context, ) { const pending = this._pendingSeek if ( !pending || pending.operationGeneration !== operationGeneration || pending.contextGeneration !== contextGeneration || pending.intentGeneration !== intentGeneration || pending.context !== context || !this.isCurrentContext(context, contextGeneration) || this._intentGeneration !== intentGeneration ) return if (this._seekTimer !== null) { clearTimeout(this._seekTimer) this._seekTimer = null } this._pendingSeek = null this.setData({ seekLocked: false }) if ( pending.playAfterSeek && this._desiredPlayback && this._activePlayIntentGeneration === intentGeneration ) { this.startPlaybackWithIntent(intentGeneration) } }, failSeek( operationGeneration, contextGeneration, intentGeneration, context, ) { const pending = this._pendingSeek if ( !pending || pending.operationGeneration !== operationGeneration || pending.contextGeneration !== contextGeneration || pending.intentGeneration !== intentGeneration || pending.context !== context || !this.isCurrentContext(context, contextGeneration) || this._intentGeneration !== intentGeneration ) return false if (this._seekTimer !== null) { clearTimeout(this._seekTimer) this._seekTimer = null } this._pendingSeek = null this._scrubbing = false const actual = Number(context.currentTime) const restoredValue = Number.isFinite(actual) ? actual : pending.previousValue if (pending.playAfterSeek) this.invalidatePlaybackIntent(false) this.setProgress(restoredValue, this.data.durationSeconds) this.setData({ playing: pending.playAfterSeek ? false : this.data.playing, loading: false, seekLocked: false, error: '进度没有调好,请再试一次。', }) return true }, previewSeek(event) { if (!this.audioContext || this.data.loading || this._pendingSeek) return this._scrubbing = true this.setProgress(eventValue(event), this.data.durationSeconds) }, commitSeek(event) { if (!this.audioContext || this.data.loading || this._pendingSeek) return this._scrubbing = false this.requestSeek(eventValue(event)) }, rewindTenSeconds() { if (!this.audioContext || this.data.loading || this._pendingSeek) return const current = Number(this.audioContext.currentTime) const fallback = Number(this.data.sliderValue) || 0 this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10)) }, replay() { if ( !this.audioContext || this.data.loading || this._controlLocked || this._pendingSeek || this._unloaded ) return const alreadyPlaying = this.data.playing && this._desiredPlayback const intentGeneration = alreadyPlaying ? this._intentGeneration : this.beginPlaybackIntent() this.setData({ hasStarted: true, error: '' }) this.requestSeek(0, { playAfterSeek: !alreadyPlaying, intentGeneration, }) }, changePlaybackRate(event) { const requested = Number( event && event.currentTarget && event.currentTarget.dataset.rate, ) if ( !RATE_VALUES.includes(requested) || !this.audioContext || !this.data.rateControlsVisible ) return const allowed = this.data.rateOptions.some((item) => item.value === requested) if (!allowed) return try { this.audioContext.playbackRate = requested if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) { this.disableRateControls(this.audioContext) return } this.setData({ playbackRate: requested }) } catch (_) { this.disableRateControls(this.audioContext) } }, goBack() { const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] const returnToChapter = () => { if (typeof wx.reLaunch === 'function') { wx.reLaunch({ url: '/package-game/pages/chapter/chapter?chapter=1', }) } } if (pages.length > 1 && typeof wx.navigateBack === 'function') { wx.navigateBack({ delta: 1, fail: returnToChapter }) return } returnToChapter() }, destroyAudioContext() { const context = this.audioContext if (!context) return this.invalidatePlaybackIntent(false) this._contextGeneration += 1 this.audioContext = null context.destroy() if (!this._unloaded) { this.setData({ audioReady: false, playing: false, loading: false, rateOptions: FALLBACK_RATE_OPTIONS, rateControlsVisible: false, playbackRate: 1, }) } }, onUnload() { this._unloaded = true this._lifecycleGeneration += 1 this._intentGeneration += 1 this._activePlayIntentGeneration = 0 this._desiredPlayback = false this._controlLocked = true this.clearPauseLock(false) this.clearSeekState(false) this.unbindAudioInterruptionHandlers() const context = this.audioContext this._contextGeneration += 1 this.audioContext = null if (context) context.destroy() }, })