Compose 封装ExoPlayer

前言

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

效果展示

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

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

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

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

准备工作

导入 ExoPlayer。

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

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

groovy 复制代码
dependencies {
    ...
    val exoplayer_version = "1.2.0"
    implementation ("androidx.media3:media3-exoplayer:$exoplayer_version")
    implementation ("androidx.media3:media3-ui:$exoplayer_version")
}

直接上代码

kotlin 复制代码
/**
 * 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)
                )
            }
        }
    }
}

使用示例

kotlin 复制代码
//最简单的使用,直接传视频网址
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,让调用者可以获取视频信息以及控制视频。
  • ......

后记

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

相关推荐
androidwork6 小时前
Android LinearLayout、FrameLayout、RelativeLayout、ConstraintLayout大混战
android·java·kotlin·androidx
每次的天空6 小时前
Android第十三次面试总结基础
android·面试·职场和发展
wu_android6 小时前
Android 相对布局管理器(RelativeLayout)
android
李斯维8 小时前
循序渐进 Android Binder(二):传递自定义对象和 AIDL 回调
android·java·android studio
androidwork8 小时前
OkHttp 3.0源码解析:从设计理念到核心实现
android·java·okhttp·kotlin
像风一样自由8 小时前
【001】frida API分类 总览
android·frida
casual_clover8 小时前
Android 之 kotlin 语言学习笔记四(Android KTX)
android·学习·kotlin
移动开发者1号10 小时前
Android 大文件分块上传实战:突破表单数据限制的完整方案
android·java·kotlin
移动开发者1号10 小时前
单线程模型中消息机制解析
android·kotlin
每次的天空13 小时前
Android第十五次面试总结(第三方组件和adb命令)
android