const { getApprovedRemotePageAudio, } = require('../../data/remotePageAudioManifest') const { releaseAssets, } = require('../../data/releaseAssetManifest') const assetReleaseConfig = require('../../data/assetReleaseConfig') const { createAssetPlatformFacade, createAssetManager, } = require('../../utils/assetManager') const { chapterRoute, normalizeChapterNumber, } = require('../../utils/chapterRoute') const REMOTE_PAGE_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/ 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 } function resolveErrorMessage(reason) { if (reason === 'remote-disabled') { return '声音下载地址还没配置好,请返回连环画,稍后再试。' } if (reason === 'remote-failed') { return '网络不太稳,声音没有下载下来。请稍后再试。' } if (reason === 'remote-integrity-failed') { return '声音文件下载不完整,安全校验没有通过。请稍后重试。' } if (reason === 'audio-unapproved') { return '这一页的声音还在审核,暂时不能播放。' } return '这一页的声音暂时没有打开,请返回连环画,稍后再试。' } Page({ data: { pageId: '', chapterNumber: 2, pageNumber: 1, chapterLabel: '第02回', pageLabel: '第1页', playing: false, loading: false, audioReady: false, hasStarted: false, seekLocked: false, retryAvailable: false, error: '', currentLabel: '0:00', durationLabel: '0:00', durationSeconds: 1, sliderValue: 0, progressStyle: 'width:0%', playbackRate: 1, rateOptions: FALLBACK_RATE_OPTIONS, rateControlsVisible: false, }, 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._prepareGeneration = 0 this._desiredPlayback = false this._activePlayIntentGeneration = 0 this._preparePlayIntentGeneration = 0 this._controlLocked = false this._scrubbing = false this._pendingSeek = null this._seekTimer = null this._pendingPauseLock = null this._pauseLockTimer = null this._preparePromise = null this._pageAudio = null this.bindAudioInterruptionHandlers() const pageId = String(options.pageId || '').trim() const match = REMOTE_PAGE_ID_PATTERN.exec(pageId) const chapterNumber = normalizeChapterNumber(options.chapterNumber) const pageChapterNumber = match ? Number(match[1]) : 0 const pageNumber = match ? Number(match[2]) : 0 this.setData({ pageId, chapterNumber, pageNumber: pageNumber || 1, chapterLabel: `第${String(chapterNumber).padStart(2, '0')}回`, pageLabel: `第${pageNumber || 1}页`, }) if ( !match || chapterNumber < 2 || pageChapterNumber !== chapterNumber ) { this.setData({ error: '页面地址不对,请返回连环画重新打开。' }) return } const pageAudio = getApprovedRemotePageAudio(pageId, releaseAssets) if (!pageAudio) { this.setData({ error: '这一页的声音还在审核,暂时不能播放。' }) return } this._pageAudio = pageAudio const durationSeconds = Math.max(1, Number(pageAudio.durationSeconds) || 1) this.setData({ durationLabel: formatTime(durationSeconds), durationSeconds, }) }, onReady() { // Opening the leaf is text-only. Downloading starts after an explicit tap. return Promise.resolve(null) }, 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) } }, getAssetManager() { if (!this._assetManager) { this._assetManager = createAssetManager({ assetPlatform: createAssetPlatformFacade(), manifest: releaseAssets, ...assetReleaseConfig, }) } return this._assetManager }, prepareAudio(intentGeneration) { if ( !this._pageAudio || this._unloaded || intentGeneration !== this._intentGeneration || !this._desiredPlayback ) return Promise.resolve(null) this._preparePlayIntentGeneration = intentGeneration if (this._preparePromise) return this._preparePromise const prepareGeneration = this._prepareGeneration + 1 this._prepareGeneration = prepareGeneration this._controlLocked = true this.setData({ loading: true, playing: false, hasStarted: true, retryAvailable: false, error: '', }) const assetId = this._pageAudio.assetId const promise = this.getAssetManager().resolve(assetId) .then((result) => { if ( this._unloaded || this._prepareGeneration !== prepareGeneration ) return null const activeIntent = this._preparePlayIntentGeneration if (!result || !result.available || !result.uri) { if ( activeIntent === this._intentGeneration && this._desiredPlayback ) { this.invalidatePlaybackIntent(false) this.setData({ playing: false, loading: false, retryAvailable: true, error: resolveErrorMessage(result && result.reason), }) } return result || null } if ( !activeIntent || activeIntent !== this._intentGeneration || !this._desiredPlayback ) return result this.createAudioContext(result.uri) if ( activeIntent === this._intentGeneration && this._desiredPlayback ) { this.startPlaybackWithIntent(activeIntent) } return result }) .catch(() => { if ( !this._unloaded && this._prepareGeneration === prepareGeneration && this._preparePlayIntentGeneration === this._intentGeneration && this._desiredPlayback ) { this.invalidatePlaybackIntent(false) this.setData({ playing: false, loading: false, retryAvailable: true, error: resolveErrorMessage('remote-failed'), }) } return null }) .finally(() => { if (this._preparePromise === promise) this._preparePromise = null }) this._preparePromise = promise return promise }, 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, retryAvailable: 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, retryAvailable: false, 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, retryAvailable: true, 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._preparePlayIntentGeneration = 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, retryAvailable: false, 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) { const intentGeneration = this.beginPlaybackIntent() return this.prepareAudio(intentGeneration) } 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) } }, retryLoad() { if (this.data.loading || this._controlLocked || this._unloaded) return if (this.audioContext) { const intentGeneration = this.beginPlaybackIntent() this.startPlaybackWithIntent(intentGeneration) return } const intentGeneration = this.beginPlaybackIntent() return this.prepareAudio(intentGeneration) }, goBack() { const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] const returnToChapter = () => { if (typeof wx.reLaunch === 'function') { wx.reLaunch({ url: chapterRoute(this.data.chapterNumber), }) } } 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._prepareGeneration += 1 this._preparePlayIntentGeneration = 0 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() }, })