gengxin
This commit is contained in:
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,45 @@
|
||||
// First-chapter page tracks are packaged for an internal listening candidate.
|
||||
// They passed automated technical checks, but have not passed human listening,
|
||||
// elder-device listening or medical-content sign-off. Do not relabel approved.
|
||||
module.exports = Object.freeze({
|
||||
'S01-C01-P01': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 1,
|
||||
title: '开席前,少了一张桌',
|
||||
caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3',
|
||||
durationSeconds: 33.679388,
|
||||
sha256: 'daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282',
|
||||
}),
|
||||
'S01-C01-P02': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 2,
|
||||
title: '十五桌,到底去哪儿了?',
|
||||
caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3',
|
||||
durationSeconds: 49.49424,
|
||||
sha256: '7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2',
|
||||
}),
|
||||
'S01-C01-P03': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 3,
|
||||
title: '老人站在扫码牌前',
|
||||
caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3',
|
||||
durationSeconds: 30.534762,
|
||||
sha256: '0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885',
|
||||
}),
|
||||
'S01-C01-P04': Object.freeze({
|
||||
reviewStatus: 'technical-qa-pass-human-listening-pending',
|
||||
pageNumber: 4,
|
||||
title: '桌上已经够吃了',
|
||||
caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”',
|
||||
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg',
|
||||
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3',
|
||||
durationSeconds: 46.79424,
|
||||
sha256: 'ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167',
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,713 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"disableScroll": true
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<view class="audio-leaf">
|
||||
<view class="audio-leaf-topbar">
|
||||
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画">‹ 返回连环画</button>
|
||||
<view class="leaf-heading">
|
||||
<text class="leaf-kicker">第01回 · 有声夹页</text>
|
||||
<text class="leaf-count">{{pageNumber}}/8</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="audio-leaf-spread">
|
||||
<view class="leaf-art-frame">
|
||||
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
|
||||
<text class="leaf-stamp">桂香故事</text>
|
||||
</view>
|
||||
|
||||
<view class="leaf-copy">
|
||||
<text class="leaf-title">{{title}}</text>
|
||||
<text class="leaf-caption">{{caption}}</text>
|
||||
|
||||
<view class="leaf-seek-row">
|
||||
<slider
|
||||
class="leaf-slider"
|
||||
aria-label="播放进度,可左右拖动"
|
||||
min="0"
|
||||
max="{{durationSeconds}}"
|
||||
step="1"
|
||||
value="{{sliderValue}}"
|
||||
activeColor="#9b3328"
|
||||
backgroundColor="#d6c6a1"
|
||||
block-color="#8f271f"
|
||||
block-size="24"
|
||||
disabled="{{loading || !audioReady || seekLocked}}"
|
||||
bindchanging="previewSeek"
|
||||
bindchange="commitSeek"
|
||||
></slider>
|
||||
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
|
||||
</view>
|
||||
|
||||
<view class="leaf-controls">
|
||||
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
|
||||
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
|
||||
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
|
||||
</button>
|
||||
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
|
||||
<text class="leaf-rate-label">语速</text>
|
||||
<button
|
||||
wx:for="{{rateOptions}}"
|
||||
wx:key="value"
|
||||
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
|
||||
data-rate="{{item.value}}"
|
||||
disabled="{{loading || seekLocked}}"
|
||||
bindtap="changePlaybackRate"
|
||||
>{{item.label}}</button>
|
||||
</view>
|
||||
|
||||
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
|
||||
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,171 @@
|
||||
page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: #1c130f;
|
||||
color: #2f241d;
|
||||
}
|
||||
|
||||
button::after { border: 0; }
|
||||
|
||||
.audio-leaf {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 320px;
|
||||
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
|
||||
}
|
||||
|
||||
.audio-leaf-topbar {
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding-right: max(92px, env(safe-area-inset-right));
|
||||
color: #f5e6b7;
|
||||
}
|
||||
|
||||
.back-top {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 48px;
|
||||
border: 1px solid #9b7a51;
|
||||
border-radius: 10px;
|
||||
background: #38251b;
|
||||
color: #f7e8bd;
|
||||
font-size: 20px;
|
||||
line-height: 48px;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
|
||||
.leaf-kicker { font-size: 20px; font-weight: 700; }
|
||||
.leaf-count { color: #d9bb78; font-size: 18px; }
|
||||
|
||||
.audio-leaf-spread {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
|
||||
border: 2px solid #b99762;
|
||||
background: #efe2bd;
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
|
||||
}
|
||||
|
||||
.leaf-art-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #17100c;
|
||||
border-right: 4px solid #8d271f;
|
||||
}
|
||||
|
||||
.leaf-art { width: 100%; height: 100%; display: block; }
|
||||
.leaf-stamp {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 16px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(86, 24, 20, .9);
|
||||
color: #f5e6bd;
|
||||
font-size: 17px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.leaf-copy {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
|
||||
}
|
||||
|
||||
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
|
||||
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
|
||||
.leaf-seek-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
|
||||
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
|
||||
.leaf-controls {
|
||||
min-width: 0;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.leaf-button, .back-bottom {
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
border: 2px solid #7e6648;
|
||||
border-radius: 10px;
|
||||
background: #eadbb5;
|
||||
color: #37291f;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.leaf-button { width: 100%; max-width: 100%; }
|
||||
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
|
||||
.leaf-button[disabled] { opacity: .72; }
|
||||
.leaf-rate-row {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
|
||||
.leaf-rate-button {
|
||||
box-sizing: border-box;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #9d896a;
|
||||
border-radius: 9px;
|
||||
background: #f3e7c8;
|
||||
color: #4d3b2d;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 46px;
|
||||
}
|
||||
.leaf-rate-button { width: 100%; max-width: 100%; }
|
||||
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
|
||||
.leaf-rate-button[disabled] { opacity: .72; }
|
||||
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
|
||||
.back-bottom { margin-top: 8px; width: 100%; }
|
||||
|
||||
@media (max-height: 430px) {
|
||||
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
|
||||
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
|
||||
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
|
||||
.leaf-title { font-size: 23px; }
|
||||
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
|
||||
.leaf-seek-row { min-height: 48px; }
|
||||
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
|
||||
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
|
||||
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
|
||||
.leaf-rate-label { font-size: 16px; }
|
||||
.back-bottom { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user