4. 可组合播放控制器:播放/暂停、进度条、SeekBar、时间显示与手势交互
播放控制器是用户与视频交互的核心界面。本章在 Compose 中构建一个完整、可组合的播放控制器,集成播放/暂停按钮、拖动式进度条(SeekBar)、当前时间与总时长显示,以及滑动手势调整进度。所有控制逻辑基于 ExoPlayer 的 Player 接口,通过 Compose 状态驱动实现响应式更新。
4.1 原理:状态桥接与事件分发
ExoPlayer 的播放状态(播放/暂停、进度、缓冲)通过回调或轮询获取。Compose 使用 State<Boolean> 和 State<Long> 作为 UI 状态的唯一来源。原理流程如下:
- 播放/暂停状态:监听
Player.Listener.onIsPlayingChanged更新mutableStateOf(false)。 - 进度状态:在
LaunchedEffect中每 250ms 轮询player.currentPosition并更新mutableStateOf(0L)。 - SeekBar 拖动:用户拖动时暂停轮询,拖动结束后调用
player.seekTo()并恢复轮询。 - 手势交互:通过
Modifier.pointerInput检测横向滑动,根据滑动距离计算偏移量并临时修改进度显示,释放时触发 seek。
4.2 实现流程
| 步骤 | 说明 |
|---|---|
| 1 | 定义播放控制器状态类 PlaybackState 封装 isPlaying、currentPosition、duration |
| 2 | 创建可组合函数 PlayerController(player: Player),接收 ExoPlayer 实例 |
| 3 | 布局:横向 Row 包含播放/暂停图标、SeekBar(Slider)、时间文本 |
| 4 | 绑定播放/暂停:IconButton(onClick = { player.playWhenReady = !player.playWhenReady }) |
| 5 | 绑定 SeekBar:Slider(value = progressFraction, onValueChange = { ... }, onValueChangeFinished = { player.seekTo(...) }) |
| 6 | 显示时间:Text(formatDuration(currentPosition)) 与 Text(formatDuration(duration)) |
| 7 | 添加手势:在外部包裹 Box,使用 Modifier.pointerInput 检测拖动并映射到进度变化 |
4.3 核心代码实现
以下为完整可组合控制器示例,包含播放/暂停、SeekBar 及时间显示:
@Composable
fun PlayerController(player: Player?) {
if (player == null) return
val isPlaying = remember { mutableStateOf(player.playWhenReady) }
val currentPosition = remember { mutableStateOf(player.currentPosition) }
val duration = remember { mutableStateOf(player.duration) }
// 监听播放状态变化
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
this@PlayerController.isPlaying.value = isPlaying
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
duration.value = player.duration
}
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
// 轮询更新当前位置
LaunchedEffect(player) {
while (isActive) {
if (isPlaying.value) {
currentPosition.value = player.currentPosition
}
delay(250)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 播放/暂停按钮
IconButton(onClick = {
player.playWhenReady = !player.playWhenReady
}) {
Icon(
imageVector = if (isPlaying.value) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying.value) "暂停" else "播放"
)
}
// 当前时间
Text(
text = formatDuration(currentPosition.value),
style = MaterialTheme.typography.bodySmall
)
// SeekBar(Slider)
Slider(
value = if (duration.value > 0) (currentPosition.value.toFloat() / duration.value) else 0f,
onValueChange = { fraction ->
currentPosition.value = (fraction * duration.value).toLong()
},
onValueChangeFinished = {
player.seekTo(currentPosition.value)
},
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
// 总时长
Text(
text = formatDuration(duration.value),
style = MaterialTheme.typography.bodySmall
)
}
}
private fun formatDuration(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "%02d:%02d".format(minutes, seconds)
}
4.4 手势交互:滑动调节进度
手势交互用于快速滑动跳转。在控制器外层包裹 Box 并添加触摸监听:
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { /* 可暂停轮询 */ },
onDrag = { change, dragAmount ->
// 根据控件宽度计算进度增量
val width = size.width.toFloat()
val deltaFraction = dragAmount / width
val newFraction = (currentFraction + deltaFraction).coerceIn(0f, 1f)
currentPosition.value = (newFraction * duration.value).toLong()
},
onDragEnd = {
player.seekTo(currentPosition.value)
}
)
}
) {
PlayerController(player)
}
4.5 调试与验证
在模拟器或真机运行后,通过 Logcat 过滤 “PlayerController” 观察状态变化:
PlayerController: onIsPlayingChanged -> true
PlayerController: currentPosition = 12345 ms
PlayerController: seekTo called -> 30000 ms
确认播放/暂停按钮切换时图标立即变化,SeekBar 拖动时进度条跟随手指运动,释放后跳转准确。
4.6 常见错误与解决方案
| 错误现象 | 原因 | 解决方案 |
|---|---|---|
| 按钮点击无效 | player 为 null 或未初始化 | 在 LaunchedEffect 中确保 player 已准备就绪 |
| Slider 卡顿 | 拖动时频繁触发重组 | 使用 derivedStateOf 或 snapshotFlow 限制重组范围 |
| 手势与 Slider 冲突 | 两者同时响应触摸事件 | 手势只作用于空白区域,Slider 自身拖动不受影响 |
| 时间显示不更新 | LaunchedEffect 被挂起 | 确保 isActive 在组件生命周期内为 true |
4.7 性能与稳定性优化
- 限制轮询频率:使用
delay(250)避免每帧更新,平衡进度精度与性能。 - 使用 derivedStateOf:将
currentPosition.value / duration.value转为derivedStateOf,减少 Slider 不必要的重组。 - 拖动时暂停轮询:在
onValueChange或手势开始时设置标志位,避免写入冲突。 - 避免 lambda 重建:使用
remember(player)缓存 listener 对象,防止重复注册。 - 线程安全:所有 UI 状态更新都在主线程(Compose 自动保证),ExoPlayer 回调已在主线程发出,无需额外同步。
4.8 播放控制器结构图
💡 提示: 真实项目中建议将
PlayerController 设计为无状态组件,通过上层 ViewModel 管理 PlaybackState,便于单元测试与状态恢复。
⚠️ 注意: 手势交互中计算进度增量时需考虑
contentScale 与系统密度,避免在不同屏幕密度下跳转偏差过大。