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

后记

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

相关推荐
夜雪一千5 小时前
如何在MySQL中实现字段级加密
android·数据库·mysql
夜雪一千5 小时前
MySQL 如何存储经纬度坐标:选型、建表、空间查询实战
android·mysql·adb
JMchen5 小时前
集成第三方 C/C++ 库到 Android NDK 项目:OpenCV 与 FFmpeg 实战指南
android·opencv·cmake
有毒的教程7 小时前
Dev-C++ 使用扩展库两种方式:DevPak一键安装 / 手动配置(重点)
android·c++·adb
mmsx7 小时前
Android 测绘数值基础:测量单位系统与七四参数、高程拟合
android
郑州光合科技余经理7 小时前
出海外卖平台:支付对接为什么要和下单收款分开
android·java·开发语言·前端·后端·php·ai编程
夜雪一千7 小时前
MySQL 如何提高查询速度:从索引、SQL、表结构到实战调优
android·sql·mysql
终端安全笔记8 小时前
配置锁状态那一栏查的是什么:报告里几项各自指向什么风险
android·前端·安全·ios
恋猫de小郭8 小时前
Kotlin Toolchain 0.13,JetBrains 推荐 KMP 都切换到到全新工具链
android·前端·flutter
安卓机器8 小时前
rom定制系列------小米8 安卓16线刷原生固件 体验新版本
android·小米8安卓16