SuperAPI AI 工具集

audio-focus-multiplayer

编程与开发

查看项目

更新于 10-17 16:16

技能介绍

用途

该技能用于解决 Android 应用中同时存在多个播放器实例时的音频焦点管理问题。Android 默认的每播放器焦点处理假设整个应用生命周期内只有一个播放器,而交叉淡入淡出等场景会不断创建、提升和释放播放器实例,从而打破这一假设。

问题背景

在交叉淡入淡出中,释放即将退出的实例会放弃整个应用的音频焦点;而新进入的实例在构建时关闭了焦点处理,因此不会重新申请焦点。结果是应用在无焦点状态下播放,系统会在下一个时机暂停它,或者其它应用接管后它再也无法恢复。

主要能力

  • 由负责切换的组件统一持有一次焦点请求,使焦点在每次切换后依然存活,因为没有任何播放器实例持有过它。
  • 所有播放器实例(包括预缓存的实例)均设置 handleAudioFocus = false。
  • 提供幂等的焦点申请方法,可在多条启动播放的路径上安全调用。

使用方式

构建播放器时关闭焦点处理:

ExoPlayer.Builder(context)
    .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ false)  // every instance
    .build()

使用惰性字段持有单个焦点请求:

private val audioFocusRequest by lazy {
    AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
        .setAudioAttributes(/* USAGE_MEDIA, CONTENT_TYPE_MUSIC */)
        .setOnAudioFocusChangeListener(audioFocusListener)
        .setWillPauseWhenDucked(false)     // we duck ourselves
        .build()
}

幂等的焦点申请:

private fun requestAudioFocusInternal(): Boolean {
    if (hasAudioFocus) return true         // idempotent
    hasAudioFocus = audioManager.requestAudioFocus(audioFocusRequest) ==
        AudioManager.AUDIOFOCUS_REQUEST_GRANTED
    return hasAudioFocus
}

陷阱

  • 仅在“主”实例上保留 handleAudioFocus = true。不存在主实例——持有焦点的实例在每次切换结束时都会被释放。应对每个构建的实例(包括预缓存的实例)都设为 false,让适配器持有唯一的请求。
  • 仅从播放按钮申请焦点。播放也会从用户未点击的路径启动:启动新进入实例的切换,以及队列推进时使用的加载并播放路径。每条路径都必须调用幂等的申请。由于在持有焦点时它是空操作,在三处调用没有成本,并能堵住瞬时丢失后应用在无焦点状态下恢复的缺口。
  • 每次释放播放器都放弃焦点。只在播放真正结束的地方放弃——stop()、release(),以及将播放交给另一个输出的分支。作为切换一部分的释放不得触碰焦点。将请求对象本身保留为单个惰性字段:用与已授予的请求对象不同的对象放弃焦点不会产生任何效果,并且会静默失败。
  • 在与淡入淡出斜坡相同的增益线上做 ducking。交叉淡入淡出在每次切换中会重写播放器音量约 50 次。写入同一行的 duck 会被下一个斜坡步骤擦除,而当 duck 解除时它会在淡入淡出进行中写入满音量——听起来就像音乐在切换中途跳回满音量。应在淡入淡出标志上对两个方向都加保护:
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK ->
    if (!isCrossfading) currentPlayer?.volume = internalVolume * duckVolumeFactor

AudioManager.AUDIOFOCUS_GAIN ->
    if (!isCrossfading) currentPlayer?.volume = internalVolume   // don't fight the ramp

这是一种变通方法,不是根治方案。结构性修复是睡眠定时器淡出已经需要的:给 duck 自己的增益线,并在音频路径上将各条线相乘,这样任何写入者都无法擦除另一个。一旦出现第二条自动增益就应采用该方案——一条线上两个写入者就是这种模式,每新增一个都会使交互翻倍。

  • 将 duck 恢复到硬编码的 1.0f。应恢复到用户级别(internalVolume),绝不要恢复到满音量。恢复到满音量会在每次导航提示后静默丢弃音量设置。
  • 将三种丢失同等对待。它们含义不同,需要不同的响应:
焦点变化 响应
LOSS 永久——暂停,清除恢复标志,标记焦点未持有
LOSS_TRANSIENT 暂停,并记录当时是否确实在播放,以便仅在此时恢复
LOSS_TRANSIENT_CAN_DUCK 衰减,不暂停
GAIN 恢复级别,仅在标志已设置时恢复播放

在 GAIN 时无条件恢复会播放用户在中断前已暂停的音乐。

  • 在程序化淡入淡出运行时 ducking。duck 和睡眠定时器淡出是两种独立的衰减。一旦各自拥有自己的线,答案就只是它们相乘;共享一条线会让它成为一个没有好答案的问题。
  • 假设焦点和媒体会话一致。切换会将会话的监听器移到新进入的实例。焦点无关且是应用范围的。不要从一方推导另一方,也不要让会话自身的焦点处理与你的同时启用——两个所有者会互相冲突。

验证方法

  • 在每次申请和每次放弃时记录授予结果。从第一个项目到最后一个项目之间,hasAudioFocus 绝不能变为 false;队列中间出现 false 就是 bug。
  • 在屏幕关闭的情况下播放至少三次切换。播放器在项目之间中断是切换时焦点丢失的典型特征。
  • 在稳定播放期间和淡入淡出中途分别触发 duck(导航提示,或任何请求 transient-can-duck 的应用),并确认两种情况后级别都恢复到用户级别。
  • 在播放中途和淡入淡出中途接听电话。正确:暂停,然后以正确级别恢复。错误:以满音量恢复,或在你已暂停时恢复。
  • grep -n "requestAudioFocus\|abandonAudioFocus" 应显示一个请求对象、来自每条启动音频路径的申请,以及仅来自终止路径的放弃。

适用场景

适用于 Android 应用中同时存在多个播放器实例的场景,例如双播放器交叉淡入淡出、预缓存播放器等。

限制

该技能描述的 ducking 保护是一种变通方法,而非根治方案;结构性修复需要为 duck 提供独立的增益线并在音频路径上相乘。