用途
某些设计需要为每个曲目创建新的播放器对象——每个对象只持有一个媒体项,过渡期间同时存在两个。而会话、通知、UI 等上层组件希望持有一个永不改变的对象。一个委托可被替换的 ForwardingPlayer 可以桥接这两者:
internal class DelegatingForwardingPlayer(initial: Player) : ForwardingPlayer(initial) {
fun swapDelegate(newDelegate: Player) { … }
}
主要能力
替换流程为:快照监听器 → 从旧委托分离视频输出 → 移除监听器 → 替换委托 → 重新添加监听器 → 重新附加视频输出 → 校验。
使用方式与陷阱
监听器绑定
监听器在添加时即绑定到当时的委托。包装器会为每个监听器针对当前委托注册一个内部适配器;替换委托后这些适配器仍指向旧对象。从外部看表现为“第一个曲目一切正常,之后通知和 UI 卡死”。需要自行跟踪每个监听器并循环处理:
private val tracked = mutableListOf<Player.Listener>()
override fun addListener(l: Player.Listener) { tracked.add(l); super.addListener(l) }
override fun removeListener(l: Player.Listener) { tracked.remove(l); super.removeListener(l) }
// in swapDelegate: tracked.toList() → super.removeListener each → set field → super.addListener each
// toList() snapshots defensively: a listener that re-enters add/removeListener mid-cycle would
// otherwise mutate tracked while this loop iterates it.
反射访问委托字段
委托字段无法通过公开 API 写入。在 companion 中反射获取一次并缓存句柄;每次替换都查找既慢又缺少统一的失败点。查找失败时应显式报错,而不是跳过替换、留下旧委托——静默的空操作替换比抛出错误更难诊断:
private val PLAYER_FIELD: Field? = try {
ForwardingPlayer::class.java.getDeclaredField("player").apply { isAccessible = true }
} catch (e: Exception) { Logger.e(TAG, "…", e); null }
这属于版本耦合:固定库版本,并在每次升级后重新运行一次替换。
视频输出
替换前先从旧委托分离视频输出,替换后再重新附加。原生视频表面一次只能连接一个解码器;保持连接会导致新播放器附加失败并使应用停止。且没有任何机制会替你向新委托重新发送表面,若不重新附加,视频会停止渲染而音频继续。记住设置的是哪种输出——surface、surface view、texture view、surface holder——因为需要再次调用对应的 setter。
播放列表导航
每个委托只持有一个媒体项,因此其自身的 hasNextMediaItem()/hasPreviousMediaItem() 始终为 false,会话据此认为没有可跳转项:next/previous 会从通知和外部控制器中消失。需要注入一个由真实队列支撑的小型 provider,并同时覆盖按命令查询和命令集:
override fun getAvailableCommands(): Player.Commands {
val nav = playlistNavigationProvider ?: return super.getAvailableCommands()
return super.getAvailableCommands().buildUpon().apply {
add(Player.COMMAND_SEEK_TO_PREVIOUS) // always: restart current track
if (nav.hasNextMediaItem()) { add(Player.COMMAND_SEEK_TO_NEXT); add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) }
if (nav.hasPreviousMediaItem()) { add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) }
}.build()
}
只覆盖 isCommandAvailable 是常见的半吊子修复:某些界面读取的是 Commands 集合,仍然什么都不显示。
不要覆盖项数或当前索引。报告真实队列位置很诱人,但这是唯一会让应用停止的覆盖:会话层会校验 currentMediaItemIndex < timeline.windowCount,而委托的 timeline 确实只有一个窗口。索引和计数必须与委托保持一致(count 1、index 0);只有导航答案来自你的队列。
“Previous”和“previous item”是两个不同的命令。seekToPrevious 带有“超过三秒则重播当前曲目”的规则,这是用户对硬件按钮的预期。seekToPreviousMediaItem 必须始终移动。两者都保留在 provider 中,无条件的那个默认调用另一个,这样实现者在不关心区别时可以忽略它:
interface PlaylistNavigationProvider {
fun hasNextMediaItem(): Boolean
fun hasPreviousMediaItem(): Boolean
fun seekToNext()
fun seekToPrevious()
fun seekToPreviousMediaItem() = seekToPrevious()
}
播放意图路由
play()/pause()/setPlayWhenReady() 需要同样的路由,否则意图会落到原始委托上。ForwardingPlayer 自身的实现作用于当前附加的委托,而不是跨替换跟踪你的播放意图。系统界面(通知、蓝牙、耳机、Android Auto)通过包装器调用会恢复该委托的音频,而你自己的“下一项是否自动播放”记录保持不变——于是下一次曲目切换替换读到过时意图,加载进来的曲目处于暂停状态。当前曲目继续播放;过渡静默地不发生。覆盖这三个方法并将它们路由到已经回答上述导航问题的同一个 provider:
override fun play() {
val nav = playlistNavigationProvider
if (nav != null) nav.play() else super.play()
}
pause() 和 setPlayWhenReady() 同理;只有在完全没有 provider 时 super.X() 才是正确的——缺少这三件套的替换会让当前曲目正常播放,而在下一曲目时静默无声。
事件重放
没有人会重放新委托已经触发过的事件。新播放器在替换前已被准备、赋予媒体项甚至可能已启动,因此你刚重新附加的监听器错过了它的项过渡和元数据事件,通知会继续显示上一曲目。替换后立即手动派发它们——项过渡、元数据,然后是 available-commands 以便按钮重新评估——每个都放在自己的 try/catch 中,这样一个坏监听器不会中止其余部分。
退化替换与校验
对新委托就是当前委托的情况提前返回(否则循环会无意义地移除并重新添加每个监听器),并在设置字段后读回,若未生效则大声记录日志。静默失败的替换表现为“应用播放旧曲目的音频却显示新曲目的封面”,没人能快速诊断。
验证方法
从 core 子模块根目录运行。列出 provider 接口的成员,再列出包装器自身的覆盖,确认每个 provider 名称都作为 override fun 重新出现——那里的缺口意味着某个传输方法被静默地直接路由到 super:
FILE=media/media3/src/main/java/com/maxrave/media3/exoplayer/DelegatingForwardingPlayer.kt
sed -n '/interface PlaylistNavigationProvider/,/^ }/p' "$FILE" | grep -oE 'fun [a-zA-Z]+'
grep -oE 'override fun [a-zA-Z]+' "$FILE" | sort -u
行为上:开始播放,从通知和耳机按钮暂停与恢复,让曲目自然结束,确认下一项自动播放。
适用场景
适用于需要在运行时替换播放器委托、同时保持媒体会话与 UI 引用稳定的 Android/Media3 播放场景。
限制
依赖对 ForwardingPlayer 内部字段的反射访问,与库版本耦合,升级后需重新验证。