白噪音页面包含雨声、海浪和篝火三个场景。场景变化不仅替换标题,还会改变符号、渐变背景、按钮颜色和提示。
如果正在“播放雨声”时直接切到海浪却保留playing = true,页面会立即显示海浪正在播放,但实际播放器并没有完成音源切换。当前版本因此在选择场景时主动复位播放状态。
interfaceSoundItem{id:numbername:stringsubtitle:stringsymbol:stringcolorA:stringcolorB:string}@StatecurrentIndex:number=0@Stateplaying:boolean=false@Statevolume:number=55@StatetimerMinutes:number=30当前场景由索引读取,切换时同步清理播放状态并更新提示;播放按钮只负责开始与暂停。
privatecurrent():SoundItem{returnthis.sounds[this.currentIndex]}privatechoose(index:number):void{this.currentIndex=indexthis.playing=false// 切换场景后等待重新开始this.message=`已切换到${this.current().name}`}privatetoggle():void{this.playing=!this.playingthis.message=this.playing?`${this.current().name}正在播放`:'已经暂停'}顶部视觉区域全部读取current():名称、说明、符号和渐变颜色会在一次索引更新后同步变化。音量与定时选项则独立保存,切换场景不会丢失用户设定。
.linearGradient({angle:165,colors:[[this.current().colorA,0.0],[this.current().colorB,1.0]]})Slider({value:this.volume,min:0,max:100}).selectedColor(this.current().colorA).onChange((value:number)=>{this.volume=value})这里把“场景相关状态”和“全局控制状态”分开:主题色、标题和符号随场景变化,音量和定时设置在场景切换时保留。若把音量也放进SoundItem,每个场景可以有独立音量,但当前需求并不需要这种复杂度。
定时关闭按钮直接修改timerMinutes,并根据当前值切换颜色。它目前只是设置项,没有真正启动倒计时,这一点从代码也能看出来:
ForEach([15,30,60],(minutes:number)=>{Button(`${minutes}`).fontSize(12).fontColor(this.timerMinutes===minutes?Color.White:'#606B7E').backgroundColor(this.timerMinutes===minutes?this.current().colorA:'#EEF1F5').onClick(()=>{this.timerMinutes=minutes})})播放按钮同样只切换布尔状态与文案。页面表现已经形成闭环,但音频能力仍是下一层实现,不能把UI状态当作播放器回调状态。
privateasyncprepareSound(index:number):Promise<void>{constversion:number=++this.requestVersionthis.phase='preparing'awaitthis.releasePlayer()constplayer:media.AVPlayer=awaitmedia.createAVPlayer()if(version!==this.requestVersion){awaitplayer.release()return}this.player=playerthis.bindPlayerEvents(player,version)player.url=this.sounds[index].source}音频资源释放也要进入组件生命周期。切换场景、页面退出和播放失败都可能持有播放器对象,必须定义统一清理路径;否则多次进入页面后可能残留音频或占用资源。当前纯状态版本没有创建播放器对象,因此没有伪造这部分代码。
场景切换与播放开关是两类事件
场景列表只维护当前索引,播放按钮只维护播放状态。切换音源时主动回到暂停,避免页面已经显示“海浪”,播放器却还在继续上一段雨声:
privatecurrent():SoundItem{returnthis.sounds[this.currentIndex]}privatechoose(index:number):void{this.currentIndex=indexthis.playing=falsethis.message=`已切换到${this.current().title}`}privatetoggle():void{this.playing=!this.playingthis.message=this.playing?'正在播放':'已暂停'}索引适用于当前固定数组;如果允许用户下载、删除和排序音源,当前项应改存稳定 ID,再通过查找得到对象。否则删除当前项之前的一条记录会使索引指向另一个场景。
privateapplyVolume(value:number):void{this.volume=Math.max(0,Math.min(100,value))constgain:number=this.volume/100this.player?.setVolume(gain)}privateasynctogglePlayer():Promise<void>{if(!this.player||!this.canToggle())returnif(this.phase==='playing'){awaitthis.player.pause()}else{awaitthis.player.play()}}调用播放接口并不等于已经发声,准备、缓冲、播放和失败需要独立状态;按钮文案应监听状态回调,而不是在点击瞬间直接假定成功。
接入音频后的状态机
实际实现可以把页面状态划分为 idle、preparing、ready、playing、paused、error。选择场景后进入 preparing,资源准备完成后进入 ready;
privatebindPlayerEvents(player:media.AVPlayer,version:number):void{player.on('stateChange',(state:string)=>{if(version!==this.requestVersion)returnif(state==='prepared')this.phase='ready'if(state==='playing')this.phase='playing'if(state==='paused')this.phase='paused'if(state==='error')this.phase='error'})}用户修改为“不开启”时取消任务,切换音源时保留还是重置定时器则要形成一致规则。真正到时后调用暂停并更新播放器状态,不能只把按钮文字改成“已停止”。
页面生命周期与异常恢复
组件销毁时清理计时器和播放器监听,音频是否继续后台播放则由产品能力决定。若不支持后台播放,页面离开即暂停并释放;若支持,就把播放器迁移到后台服务,页面只订阅服务状态。
privateasyncreleasePlayer():Promise<void>{if(!this.player)returnthis.player.off('stateChange')awaitthis.player.release()this.player=undefinedthis.phase='idle'}aboutToDisappear():void{this.cancelSleepTimer()this.requestVersion++this.releasePlayer()}当前原型只验证页面交互,因此没有用虚假的计时器伪装真实播放,这让后续接入系统媒体能力时边界更清楚。
可重复的交互验证
原型运行时依次验证四条链路:选择海浪后当前卡片和标题同步且播放状态复位;连续点击播放与暂停时文案不漂移;拖动音量只改变数值,不意外切换音源;修改定时选项后只保留一个当前值。
privatearmSleepTimer(minutes:number):void{this.cancelSleepTimer()this.timerMinutes=minutesthis.sleepEndAt=Date.now()+minutes*60*1000this.sleepTimerId=setInterval(()=>{if(Date.now()<this.sleepEndAt)returnthis.player?.pause()this.phase='paused'this.cancelSleepTimer()},1000)}privatecancelSleepTimer():void{if(this.sleepTimerId>=0)clearInterval(this.sleepTimerId)this.sleepTimerId=-1}音量、音源和播放阶段还可以记录为调试快照,但日志只保留枚举和数值,不输出本地资源路径或用户数据。复现异常时按选择事件、准备回调、播放命令、状态回调的顺序查看,能够判断问题发生在页面分发还是媒体层。
播放按钮由阶段决定是否可用
播放器状态不再压缩成一个布尔值:
typePlayerPhase='idle'|'preparing'|'ready'|'playing'|'paused'|'error'privatecanToggle():boolean{return['ready','playing','paused'].includes(this.phase)}privateplayButtonText():string{if(this.phase==='preparing')return'准备中'if(this.phase==='playing')return'暂停'if(this.phase==='error')return'重新加载'return'播放'}按钮禁用、加载反馈和错误重试都直接映射播放器阶段,页面不会提前显示“正在播放”。