Compose 封装ExoPlayer

3,447 阅读4分钟

前言

项目中播放网络视频的需求应该算是较为常见的,不论是短视频类的 App 还是电商类的 App,都离不开视频播放。但是很遗憾在 Compose 中暂时还没有关于视频播放的官方轮子。所幸 Compose 对原生 View 是支持的,所以基于官方出品的 ExoPlayer 做一个 Compose 的封装。封装不太完善并且达不到轮子的程度,有类似需求的看官们自行甄别,理性发表意见。

效果展示

Screen_recording_202 -big-original.gif

第一个是直接放横屏视频的样子。

第二个是使用了 ExoPlayer 内置控制器的样子。

第三个是直接放竖屏视频的样子(占用过多空间)。

第四个是使用了isFixHeight参数控制高度的样子。

准备工作

导入 ExoPlayer。

官方推荐使用 AndroidX 包中 Media3 下的 ExoPlayer。旧的 ExoPlayer 应该也是类似的。

在 app 的build.gradle.kts文件中添加依赖

dependencies {
    ...
    val exoplayer_version = "1.2.0"
    implementation ("androidx.media3:media3-exoplayer:$exoplayer_version")
    implementation ("androidx.media3:media3-ui:$exoplayer_version")
}

直接上代码

/**
 * Compose 封装的 ExoPlayer
 * @param data String? 视频的网络地址
 * @param modifier Modifier
 * @param isFixHeight Boolean 用于指定控件是否固定高度,true 则需传入与高度相关的 Modifier;false 则根据视频比例调整
 * @param useExoController Boolean 是否使用 ExoPlayer 的内置控制器
 * @param cache Cache? 视频缓存功能,null 表明不启用缓存
 * @param onSingleTap 单击视频控件事件回调
 * @param onDoubleTap 双击视频控件事件回调
 * @param onVideoDispose ExoPlayer release 后的回调
 * @param onVideoGoBackground 后台事件回调
 */
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
    data: String?,
    modifier: Modifier = Modifier,
    isFixHeight: Boolean = false,
    useExoController: Boolean = false,
    cache: Cache? = null,
    onSingleTap: (exoPlayer: ExoPlayer) -> Unit = {},
    onDoubleTap: (exoPlayer: ExoPlayer, offset: Offset) -> Unit = { _, _ -> },
    onVideoDispose: () -> Unit = {},
    onVideoGoBackground: () -> Unit = {}
) {
    val context = LocalContext.current
    //初始的比例,设置成这么大用来模拟 0 高度
    var ratio by remember { mutableStateOf(1000f) }
​
    //当前视频播放的进度
    var currentPosition by remember { mutableStateOf(0L) }
    //是否在播放
    var isVideoPlaying by remember { mutableStateOf(false) }
    //自己实现的控制器是否可见
    var isControllerVisible by remember { mutableStateOf(false) }
​
    //标志是否为初次进入,防止 lifecycle 的 onStart 事件导致自动播放
    var isFirstIn by remember { mutableStateOf(true) }
​
    LaunchedEffect(isControllerVisible) {
        if (isControllerVisible) {
            //如果控制器可见,5 秒后自动消失
            delay(5000)
            isControllerVisible = false
        }
    }
​
    //实例化 ExoPlayer
    val exoPlayer = remember(context) {
        ExoPlayer.Builder(context).build().apply {
            //用视频网址构建 MediaItem
            val item = MediaItem.fromUri(Uri.parse(data))
            if (cache != null) {
                //启动缓存
                val httpDataSource =
                    DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
                val defaultDataSource = DefaultDataSource.Factory(context, httpDataSource)
                val cacheSourceFactory = CacheDataSource.Factory()
                    .setCache(cache)
                    .setUpstreamDataSourceFactory(defaultDataSource)
                    .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
                setMediaSource(
                    ProgressiveMediaSource
                        .Factory(cacheSourceFactory)
                        .createMediaSource(item)
                )
            } else {
                //不启用缓存则直接 setMediaItem
                setMediaItem(item)
            }
            //设置重复播放的模式(这里也不是很搞得懂)
            repeatMode = Player.REPEAT_MODE_ONE
            //关闭自动播放
            playWhenReady = false
            //开始准备资源
            prepare()
        }
    }
​
    val lifecycleOwner by rememberUpdatedState(LocalLifecycleOwner.current)
    DisposableEffect(lifecycleOwner) {
        val lifeCycleObserver = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_STOP -> {
                    //暂停视频播放并调用 onVideoGoBackground
                    exoPlayer.pause()
                    onVideoGoBackground()
                }
​
                Lifecycle.Event.ON_START -> if (!isFirstIn) exoPlayer.play() //恢复播放
                else -> {}
            }
        }
        lifecycleOwner.lifecycle.addObserver(lifeCycleObserver)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(lifeCycleObserver)
        }
    }
​
    //构建播放器控件
    val playerView = remember {
        //这里使用了 XML 的布局构建 view 是因为项目需要设置播放器的渲染方式,只能用 XML 的属性设置
        LayoutInflater.from(context).inflate(R.layout.video_player, null)
            .findViewById<PlayerView>(R.id.my_video_player).apply {
                player = exoPlayer
                useController = useExoController
            }
    }
​
    DisposableEffect(Unit) {
        playerView.setAspectRatioListener { targetAspectRatio, _, _ ->
            //获取到视频比例时给控件比例赋值
            ratio = targetAspectRatio
        }
        val listener = object : Player.Listener {
            override fun onIsPlayingChanged(isPlaying: Boolean) {
                //是否正在播放的监听
                isVideoPlaying = isPlaying
            }
        }
        exoPlayer.addListener(listener)
        onDispose {
            //收尾工作
            exoPlayer.removeListener(listener)
            playerView.setAspectRatioListener(null)
            exoPlayer.release()
            onVideoDispose()
        }
    }
​
    LaunchedEffect(exoPlayer) {
        while (isActive) {
            //每 1 秒读一次当前进度,用于自定义控制器的进度显示
            currentPosition = exoPlayer.currentPosition / 1000
            delay(1000)
        }
    }
​
    val singleTapWrapper: (Offset) -> Unit = {
        //单击回调装饰器,控制自定义控制器的可见性并回调 onSingleTap
        isControllerVisible = !isControllerVisible
        onSingleTap(exoPlayer)
    }
​
    val doubleTapWrapper: (Offset) -> Unit = {
        //双击回调装饰器,控制视频的暂停和播放并回调 onDoubleTap
        if (exoPlayer.isPlaying) exoPlayer.pause()
        else exoPlayer.play()
        isFirstIn = false
        onDoubleTap(exoPlayer, it)
    }
​
    val actualModifier = if (isFixHeight) {
        modifier
    } else {
        //非指定高度则设置控件比例
        modifier.aspectRatio(ratio)
    }
​
    Box(modifier = actualModifier) {
        //播放器本体
        AndroidView(
            factory = { playerView },
            modifier = actualModifier.pointerInput(Unit) {
                detectTapGestures(
                    onTap = singleTapWrapper,
                    onDoubleTap = doubleTapWrapper
                )
            }
        )
​
        //以下是自定义控制器的 UI,使用 Exoplayer 内置控制器时不显示
        if (!useExoController) {
​
            val controllerBgAlpha by animateFloatAsState(targetValue = if (isControllerVisible || isFirstIn) 0.7f else 0f)
            val controllerContentAlpha by animateFloatAsState(targetValue = if (isControllerVisible || isFirstIn) 1f else 0f)
​
            Box(
                modifier = Modifier
                    .align(Alignment.BottomEnd)
                    .padding(10.dp)
                    .height(20.dp)
                    .clip(RoundedCornerShape(6.dp))
                    .background(Color.Black.copy(alpha = controllerBgAlpha))
                    .padding(horizontal = 6.dp),
                contentAlignment = Alignment.Center
            ) {
                val formattedTime =
                    "${currentPosition / 60}:${String.format("%02d", (currentPosition % 60))}"
                Text(
                    text = formattedTime,
                    color = Color.White.copy(alpha = controllerContentAlpha),
                    style = MaterialTheme.typography.labelSmall
                )
            }
​
            Box(
                modifier = Modifier
                    .align(Alignment.BottomStart)
                    .padding(10.dp)
                    .size(30.dp)
                    .clip(CircleShape)
                    .background(Color.Black.copy(alpha = controllerBgAlpha))
                    .clickable {
                        if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
                        isFirstIn = false
                    }
                    .padding(6.dp),
                contentAlignment = Alignment.Center
            ) {
                Icon(
                    painterResource(if (isVideoPlaying) R.drawable.icon_pause else R.drawable.icon_play),
                    contentDescription = "",
                    tint = Color.White.copy(alpha = controllerContentAlpha)
                )
            }
        }
    }
}

使用示例

//最简单的使用,直接传视频网址
VideoPlayer(data = videoUrl)
​
//使用 ExoPlayer 内置控制器
VideoPlayer(data = videoUrl, useExoController = true)
​
//标志为固定高度并传入指定了高度的 modifier,通常是让竖屏视频不占用太多空间
VideoPlayer(
    data = videoUrl,
    modifier = Modifier
        .fillMaxWidth()
        .height(150.dp),
    isFixHeight = true
)
​
//增加视频缓存逻辑(我项目中尝试过使用缓存,但最后还是会发起视频请求,如果有懂的一定要指点我一下)
val videoCache = context.cacheDir.resolve("video_cache")
val lru = LeastRecentlyUsedCacheEvictor(100 * 1024 * 1024)
val cache = SimpleCache(videoCache, lru, StandaloneDatabaseProvider(context))
​
VideoPlayer(data = videoUrl, cache = cache)

改进空间

  • 对于不同尺寸(横屏竖屏)的视频适配性欠佳,isFixHeight这个参数非常不优雅。
  • 缓存逻辑貌似不生效。
  • 自定义控制器可以设计成「槽」,让调用者自行实现。
  • 可以将 ExoPlayer 的功能抽象成一个 VideoState,让调用者可以获取视频信息以及控制视频。
  • ……

后记

笔者水平有限,只是一个粗糙的成果,希望起抛砖引玉之作用。