This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -0,0 +1,872 @@
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()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,73 @@
<view class="audio-page">
<view class="audio-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画">‹ 返回连环画</button>
<view class="page-heading">
<text class="page-kicker">{{chapterLabel}} · 有声夹页</text>
<text class="page-count">{{pageNumber}}/8</text>
</view>
</view>
<view class="audio-spread">
<view class="story-card" aria-label="本页文字封面">
<view class="story-card-border">
<text class="story-card-kicker">桂香里的有声夹页</text>
<text class="story-card-title">桂香里的这一页</text>
<view class="story-card-rule"></view>
<text class="story-card-chapter">{{chapterLabel}}</text>
<text class="story-card-page">{{pageLabel}}</text>
<text class="story-card-note">声音单独下载,画面仍留在原来的连环画里。</text>
<text class="story-card-stamp">桂香故事</text>
</view>
</view>
<view class="audio-copy">
<text class="audio-title">听这一页</text>
<text class="audio-caption">点“开始听”后才下载并播放;来电话或离开页面会暂停,不会自己续播。</text>
<view class="audio-seek-row">
<slider
class="audio-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="audio-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="audio-controls">
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="audio-button primary" disabled="{{loading || seekLocked || error}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="audio-rate-row" aria-label="播放速度">
<text class="audio-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="audio-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<view wx:if="{{error}}" class="audio-error" role="alert">
<text>{{error}}</text>
<button wx:if="{{retryAvailable}}" class="retry-button" disabled="{{loading}}" bindtap="retryLoad">再试一次</button>
</view>
<button class="back-bottom" bindtap="goBack">回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,192 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-page {
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%, #443125 0, #211611 70%);
}
.audio-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;
}
.page-heading { display: flex; align-items: baseline; gap: 12px; }
.page-kicker { font-size: 20px; font-weight: 700; }
.page-count { color: #d9bb78; font-size: 18px; }
.audio-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);
}
.story-card {
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: 18px;
background: repeating-linear-gradient(0deg, rgba(245, 225, 177, .035) 0, rgba(245, 225, 177, .035) 1px, transparent 1px, transparent 5px), #251813;
border-right: 4px solid #8d271f;
}
.story-card-border {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #b99762;
outline: 1px solid rgba(185, 151, 98, .55);
outline-offset: -8px;
color: #f4e3b8;
text-align: center;
padding: 28px;
}
.story-card-kicker { color: #d1b274; font-size: 17px; letter-spacing: 3px; }
.story-card-title { margin-top: 10px; font-size: 32px; font-weight: 800; letter-spacing: 5px; }
.story-card-rule { width: 58%; height: 2px; margin: 16px 0; background: #8f3027; }
.story-card-chapter { color: #f8eac8; font-size: 28px; font-weight: 800; }
.story-card-page { margin-top: 6px; color: #d8bd83; font-size: 22px; }
.story-card-note { max-width: 430px; margin-top: 20px; color: #d8c8a5; font-size: 18px; line-height: 1.55; }
.story-card-stamp { position: absolute; right: 22px; bottom: 18px; padding: 6px 10px; border: 2px solid #9d3a2e; color: #d98673; font-size: 16px; letter-spacing: 2px; }
.audio-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;
}
.audio-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.audio-caption { margin-top: 10px; font-size: 20px; font-weight: 600; line-height: 1.48; }
.audio-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;
}
.audio-slider { min-width: 0; width: 100%; margin: 0; }
.audio-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.audio-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.audio-button,
.retry-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;
}
.audio-button { width: 100%; max-width: 100%; }
.audio-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.audio-button[disabled], .retry-button[disabled] { opacity: .72; }
.audio-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;
}
.audio-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.audio-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;
}
.audio-rate-button { width: 100%; max-width: 100%; }
.audio-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.audio-rate-button[disabled] { opacity: .72; }
.audio-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.retry-button { width: 100%; min-height: 48px; margin-top: 8px; line-height: 46px; font-size: 18px; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-page { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-topbar { height: 48px; flex-basis: 48px; }
.story-card { padding: 10px; }
.story-card-border { padding: 16px; }
.story-card-title { font-size: 26px; }
.story-card-note { margin-top: 10px; font-size: 16px; }
.audio-copy { padding: 8px 12px; overflow-y: auto; }
.audio-title { font-size: 23px; }
.audio-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.audio-seek-row { min-height: 48px; }
.audio-controls, .audio-rate-row { margin-top: 3px; gap: 5px; }
.audio-button, .retry-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.audio-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.audio-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}