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 播放控制器结构图

播放控制器组件结构 ExoPlayer 实例 Compose State (isPlaying, position) 可组合控制器 (Row 布局) 播放/暂停按钮 SeekBar (Slider) 时间显示 手势层 状态双向绑定:ExoPlayer 回调 → Compose State 用户手势 → Player.seekTo() / playWhenReady
💡 提示: 真实项目中建议将 PlayerController 设计为无状态组件,通过上层 ViewModel 管理 PlaybackState,便于单元测试与状态恢复。
⚠️ 注意: 手势交互中计算进度增量时需考虑 contentScale 与系统密度,避免在不同屏幕密度下跳转偏差过大。