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,让调用者可以获取视频信息以及控制视频。
  • ......

后记

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

相关推荐
liujingtech1 小时前
Kotlin实践下来 takeIf 并不是所有场景的银弹
android
xvch2 小时前
Kotlin 2.1.0 入门教程(二十三)泛型、泛型约束、协变、逆变、不变
android·kotlin
ianozo3 小时前
BUU40 [安洵杯 2019]easy_serialize_php
android·开发语言·php
abs6254 小时前
uniapp使用uts插件启动原生安卓Service
android·uni-app·uniapp uts插件·uniapp 安卓服务
Evaporator Core4 小时前
MATLAB在投资组合优化中的应用:从基础理论到实践
android
Neo Evolution5 小时前
Flutter与移动开发的未来:谷歌的技术愿景与实现路径
android·人工智能·学习·ios·前端框架·webview·着色器
coooliang5 小时前
Flutter项目中设置安卓启动页
android·flutter
xianrenli385 小时前
android 使用 zstd算法压缩文件
android
九思x5 小时前
Android Studio安装配置及运行
android·ide·android studio
风浅月明17 小时前
[Android]如何判断当前APP是Debug还是Release环境?
android