用途
本技能用于指导在媒体播放器中实现两个媒体项之间的交叉淡入淡出(crossfade)。核心前提是:两个媒体项必须同时可听,因此交叉淡入淡出无法建立在单个播放器实例之上。
主要能力与实现结构
适用于 Android(Media3/ExoPlayer)和桌面引擎的结构如下:
- 每个媒体项一个播放器实例。正在发声的是 currentPlayer,正在淡入的是 secondaryPlayer。即将播放的项可能已作为预缓存实例存在。
- 对每一种提前退出(暂停、跳转、跳过)都使用同一个完成动作:释放离开的实例、提升进入的实例、恢复其正常电平、清除淡入淡出标志。这称为“将进入的曲目提交为当前曲目”。自然结束通常保留一个近乎相同的自身副本;让两者保持同步——添加到其中一个的每个清理步骤都必须出现在另一个中,或提取它们共享的尾部。
- 等功率(余弦/正弦)增益曲线,由单个可取消任务驱动:
val fadeAngle = (progress * PI / 2).toFloat()
currentPlayer?.volume = targetVolume * cos(fadeAngle) // outgoing
nextPlayer.volume = targetVolume * sin(fadeAngle) // incoming
因为 cos²θ + sin²θ = 1,所以整个混合过程中总声学功率恒定。线性斜坡在中点处功率之和约为 0.5,会被听成一次下陷——响度感知是对数的,因此离开的曲目会在进入的曲目填补进来之前就“死掉”。
使用方式与陷阱
- 淡入淡出中途到达的传输命令会作用到错误的播放器上。pause() 和跳过命令通常会得到处理;seekTo(positionMs) 是每个平台上都会被遗忘的那个。它有两重失败:没有任何东西取消淡入淡出,因此离开的曲目继续在下面播放;并且跳转落在离开的实例上——而淡入淡出期间的位置更新是从进入的实例读取的,这正是进度条所显示的。从外部看,就像跳转被忽略,旧歌在新歌中阴魂不散。停留在淡入淡出对上的命令必须先提交:
override fun seekTo(positionMs: Long) {
cachedPosition = positionMs // so the bar does not snap back while queued
scope.launch {
if (isCrossfading) commitIncomingAsCurrent()
currentPlayer?.seekTo(positionMs)
}
}
- 审计方法:列出每一个会触及播放器的 override,并检查每一个是否有该标志。没有 isCrossfading 分支的命令就是一个等待用户淡入淡出并点击的 bug。该分支并不总是提交:跳转到不同项时应改为取消淡入淡出并释放进入的实例——提交会提升一个用户刚刚导航离开的曲目。审计要求的是分支本身,而不是一种固定的响应。
- 提交不得触碰监听器或转发委托。两者在淡入淡出开始时就已经指向进入的实例。在这里重新指向会丢失监听器,或分离平台媒体会话。当前项索引同样已经是进入的那个。因此提交只做:取消斜坡任务、释放离开的实例、将进入的实例移入 currentPlayer、恢复其过滤器/音量/速度、清除标志。它刻意不改变播放状态或跳转——由调用方决定是原地暂停、前进还是后退。
- 播放设置必须到达每一个活动句柄,而不仅仅是当前句柄。速度、音高、音量以及任何程序化淡入淡出因子都属于会话,而不属于实例。容易被遗漏的实例是 secondaryPlayer:它在被提升之前会从预缓存映射中移除,因此在淡入淡出期间它不属于任何一个集合。让每个 setter 都经过一个辅助函数:
private fun forEachLiveHandle(action: (Player) -> Unit) {
currentPlayer?.let(action)
secondaryPlayer?.let(action) // in neither collection during a fade
precachedPlayers.values.forEach { action(it.player) }
}
- 只对 currentPlayer 应用速度并在淡入淡出结束时重新断言,看起来正确,直到用户改变速度并跳过:下一曲目会以 1.0x 开始。
- 在共享输出线上,遗漏的句柄不会一直错下去——它会撤销其他句柄。当引擎暴露设备混音器电平(在某些平台上,一个进程的所有句柄都落在同一个输出会话中)时,一个仍持有较旧电平的句柄会在自己的输出重配置事件上重新断言该电平,并覆盖其他所有句柄。验证方法是记录每个句柄在 setter 之后认为自己持有的电平,而不是听一次。
- 新实例从引擎默认值开始。任何已经生效的设置都必须在句柄变为可听之前推送到它上面,否则进行中的淡入淡出会在斜坡中途跳回满音量。当存在两个电平时,在一次调用中设置它们——先写主电平会在共享线上发布一个满音量瞬间,其他每个句柄都会听到。
- 取消是第二条完成路径,需要自己的清理。斜坡任务被取消的地方比你预想的多——通过 grep 任务名在你自己的代码中数一数。它的 catch (CancellationException) 必须禁用两个过滤器、恢复离开实例的速度/音高、释放进入的实例并清除标志——否则被取消的淡入淡出会留下一个无人拥有的活动实例,以及一个会阻止未来所有淡入淡出且任何地方都没有错误的标志。
- 触发阈值是墙钟时间,而不是媒体时间。在 1.5x 速度下,5 秒媒体是 3.3 秒收听时间,因此在 duration - position <= fadeMs 时开始的淡入淡出会开始得太晚,并被项结束切断:
val speed = internalPlaybackSpeed.coerceAtLeast(0.1f)
val timeRemaining = ((dur - pos) / speed).toLong()
- 当下一个项尚未预缓存时,添加准备预算——否则解析和缓冲它会侵蚀混合中可听的部分。
- 从释放句柄的线程写入引擎属性。音量、淡入淡出因子、跳转和播放参数通常从 UI 线程写入,而释放发生在播放器线程上。在“它是否存活”检查和属性写入之间被释放的句柄会让应用硬崩溃。将每次写入限制在单个单线程作用域内可以关闭这个窗口——并且作为副作用,这正是让“暂停,然后恢复”真正有序的原因。
验证方法
- 开始一次淡入淡出,然后在其中暂停/跳转/跳过。正确行为:UI 已经显示的项继续播放(或原地冻结);前一项立即静音。
- 记录提交所提升的句柄 id,以及之后活动句柄的数量。淡入淡出之间的稳态恰好是 1 加上预缓存数量。
- 不要仅凭耳朵验证曲线。记录进度、两个音量,并在若干步骤上断言 out² + in² ≈ targetVolume²——两个增益都按用户电平缩放,因此平方和等于该电平的平方,并且只有在满音量时才等于 1。
适用场景与限制
适用于需要在 Android(Media3/ExoPlayer)或桌面引擎上实现双播放器交叉淡入淡出的开发场景。资料未提供具体的收费、价格、平台支持范围或效果保证等信息。