JetPack知识点实战系列十:ExoPlayer进行视频播放的实现

9 篇文章 14 订阅
1 篇文章 0 订阅

本节教程我们将来介绍下ExoPlayer的视频播放功能。

效果

我们在本节将主要介绍以下知识点:

  1. ExoPlayer高级自定义的实现
  2. 视频的全屏播放和退出全屏播放
  3. ExoPlayer在RecyclerView中的复用

ExoPlayer介绍

MediaPlayerExoPlayer是Google官方支持的两种播放器,但是ExoPlayerMediaPlayer多了支持基于 HTTP 的动态自适应流 (DASH)、SmoothStreaming 和通用加密等功能。

并且重要的是它独立于Android代码框架,以一个开源代码库的形式存在,所以在自定义上更有优势。

ExoPlayer简单的使用方法
  • 引入依赖库
implementation 'com.google.android.exoplayer:exoplayer:2.12.0'
  • 布局中引入PlayerView

播放视频我们需要使用PlayerView,我们简单来看下PlayerView的源码,其继承于FrameLayout,其中有三个重要的属性,

public class PlayerView extends FrameLayout implements AdsLoader.AdViewProvider {
    @Nullable private final View surfaceView;
    @Nullable private final PlayerControlView controller;
    private Player player;
}
  1. surfaceView是呈现视频的View,可以是TextureViewSurfaceView, 默认是SurfaceView
  2. controller是播放控制的View,上面提供一些控件可以控制视频的播放,暂停,显示当前进度等。默认是PlayerControlView
  3. player 是视频的播放器,在构造函数初始化的时候没有赋值,需要单独设置。

总结:PlayerView通过player播放视频显示在surfaceView上,用户可以通过提供的controller进行播放的控制。

介绍了基本的知识点后,我们在布局文件中引入PlayerView

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".DefaultViewActivity">

    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/video_player"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:show_buffering="always"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
  • 设置播放器
val player: SimpleExoPlayer = SimpleExoPlayer.Builder(this@MainActivity).build().also { it.playWhenReady = true }
video_player.player = player

我们前面提到PlayerView的两个属性在构造函数调用时赋值了,但是player没有,需要主动设置。这里我们设置成SimpleExoPlayer对象。

SimpleExoPlayer是库中提供的播放器,可以直接使用。

  • 设置播放源
// play item
val uri = Uri.parse("https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4")
val dataSourceFactory = DefaultHttpDataSourceFactory()
val videoSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri)
// prepare
player.prepare(videoSource)
  • 监听播放器的状态

我们可以监听播放器的状态,代码如下:

player.addListener(object: Player.EventListener {
    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
    Log.d("JJMusic","playWhenReady: $playWhenReady playbackState: $playbackState")
    when (playbackState) {
        Player.STATE_BUFFERING ->
            Log.d("JJMusic","加载中")
        Player.STATE_READY ->
            Log.d("JJMusic","准备完毕")
        Player.STATE_ENDED ->
            Log.d("JJMusic","播放完成")
        }
    }

    override fun onPlayerError(error: ExoPlaybackException) {
        Log.e("JJMusic","ExoPlaybackException: $error")
    }
})

最后得到的效果如下所示:

默认控制器

ExoPlayer简单自定义

我们目前使用的是默认的播放控制布局文件,我们可以修改播放的布局文件达到自定义效果。

  • 自定义播放控制的布局文件

假设我们把布局文件设计如下所示:

<!-- layout_video_simple.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/constraint"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/exo_play"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@null"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@mipmap/exo_btn_play" />

    <ImageView
        android:id="@+id/exo_pause"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:contentDescription="@null"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:srcCompat="@mipmap/exo_btn_pause" />

    <TextView
        android:id="@+id/exo_position"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="6dp"
        android:layout_marginBottom="12dp"
        android:contentDescription="@null"
        android:text="1"
        android:textColor="@color/colorPrimary"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <TextView
        android:id="@+id/splash_tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="1dp"
        android:layout_marginBottom="12dp"
        android:contentDescription="@null"
        android:text="/"
        android:textColor="@color/colorPrimary"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/exo_position"
        tools:text="/" />

    <TextView
        android:id="@+id/exo_duration"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="1dp"
        android:layout_marginBottom="12dp"
        android:contentDescription="@null"
        android:text="1"
        android:textColor="@color/colorPrimary"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/splash_tv" />

    <com.google.android.exoplayer2.ui.DefaultTimeBar
        android:id="@+id/exo_progress"
        android:layout_width="0dp"
        android:layout_height="15dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:bar_height="2dp"
        app:unplayed_color="@color/exo_gray_ripple"
        app:played_color="@color/colorAccent"
        app:scrubber_color="@color/colorAccent"
        app:buffered_color="@color/colorPrimary"
        />

</androidx.constraintlayout.widget.ConstraintLayout>
  1. idexo_play的按钮和idexo_pause的按钮在屏幕正中间位置
  2. idexo_position的文本和idexo_duration的文本在左下角
  3. idexo_progress的进度条在最底部。进度条的类是DefaultTimeBar,可以设置一些属性。譬如上面的bar_height(进度条的高度),unplayed_color(未缓冲部分的颜色),played_color(已播放部分的颜色)和buffered_color(已缓冲完部分的颜色)等等。

注意:这些idPlayerControlView源代码中能找到的id,否则是没有效果的。

  • 修改PlayerView布局文件
<com.google.android.exoplayer2.ui.PlayerView
    ...
    app:controller_layout_id="@layout/layout_video_simple"
    />

其他的和前面的类似,只是加了个属性controller_layout_id,值为我们刚才设计的布局文件layout_video_simple

简单自定义得到的效果如下所示:

简单自定义

ExoPlayer高级自定义

简单的自定义我们只是更改了PlayerControlView的布局文件,复用了其中的id,能修改的很有限,没有涉及到源代码的修改。

高级自定义就需要修改源代码了。其实就是修改PlayerViewPlayerControlView,甚至是TimeBar的源代码。

接下来我们就用高级自定义来实现下网易云音乐的全屏播放功能,需要的效果如下:

网易云音乐效果

  • 修改PlayerControlView

新建一个JJPlayerControlView类,然后将PlayerControlView所有源代码拷贝在这个类中。

public class JJPlayerControlView extends FrameLayout {
    // PlayerControlView内容
}

接下来在JJPlayerControlView中加入一个全屏按钮属性。

public class JJPlayerControlView extends FrameLayout {
    // 全屏按钮
    private final ImageButton maxButton;
    // PlayerControlView内容
    public JJPlayerControlView(
            Context context,
            @Nullable AttributeSet attrs,
            int defStyleAttr,
            @Nullable AttributeSet playbackAttrs) {
        ...
        maxButton = findViewById(R.id.exo_max_btn);
        if (maxButton != null) {
            maxButton.setOnClickListener(componentListener);
        }
        ...
    }
}
  • 修改PlayerView

新建一个JJPlayerView类,然后将PlayerView所有源代码拷贝在这个类中。

public class JJPlayerView extends FrameLayout implements AdsLoader.AdViewProvider {
// PlayerView的内容
}

JJPlayerViewcontroller指定为JJPlayerControlView,即:

public class JJPlayerView extends FrameLayout implements AdsLoader.AdViewProvider {
    @Nullable private final JJPlayerControlView controller;
    // PlayerView的其他内容
}
  • 修改TimeBar

如果需要修改进度条,新建一个JJTimeBar类,然后将DefaultTimeBar所有源代码拷贝在这个类中。

public class JJTimeBar extends View implements TimeBar {
    ...
}

当然修改将JJPlayerControlView中的timeBar改为JJTimeBar类。

public class JJPlayerControlView extends FrameLayout {
    // 全屏按钮
    private final ImageButton maxButton;
    // 自定义进度条
    @Nullable private JJTimeBar timeBar;
    // PlayerControlView内容
    public JJPlayerControlView(
            Context context,
            @Nullable AttributeSet attrs,
            int defStyleAttr,
            @Nullable AttributeSet playbackAttrs) {
        ...
        maxButton = findViewById(R.id.exo_max_btn);
        if (maxButton != null) {
            maxButton.setOnClickListener(componentListener);
        }
        ...
    }
}
  • 修改JJPlayerControlView布局文件

自定义的布局文件

layout_video_recyclerview.xml相对前面,我们多添加了一个idexo_max_btn的按钮。

为了看的更加明显,我把其他的按钮或者文本的id都改了,不再使用默认的id,这时候为了找到对应的控件,就需要修改对应的源代码了。譬如我把播放按钮的id改为了exo_play_btn

public class JJPlayerControlView extends FrameLayout {
    // 代码修改
    playButton = findViewById(R.id.exo_play_btn);
    if (playButton != null) {
        playButton.setOnClickListener(componentListener);
    }
}
  • JJPlayerView布局文件

JJPlayerView使用JJPlayerControlView自定义的布局文件

<com.johnny.jjmusic.exoplayer.JJPlayerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:show_buffering="always"
    app:controller_layout_id="@layout/layout_video_recyclerview"
    >

</com.johnny.jjmusic.exoplayer.JJPlayerView>
  • 全屏和退出全屏的实现逻辑

我们先来看一张图就能很清晰的了解全屏和退出全屏的逻辑了:

全屏逻辑

全屏的时候JJPlayerView放在ActivityR.id.content上,隐藏ActionBar,切换成横屏显示,退出全屏的时候就重新放在RecyclerViewItemView上,显示ActionBar,切换成竖屏显示。

所以最后很简单,只要处理maxButton点击事件时实现这个功能就可以了。

进入全屏播放

fun enterFullScreen() {
    // 横竖屏状态判断
    if (viewModel.playMode == VideoPlayMode.MODE_FULL_SCREEN) return
    // 隐藏ActionBar
    playerView.context.hideActionBar()
    // 旋转屏幕
    playerView.context.activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
    // 将JJPlayerView从RecyclerView移除,加入Activity的R.id.content下
    playerView.context.activity?.let {
        val contentView = it.findViewById<ViewGroup>(android.R.id.content)
        // remove
        removePlayerView()
        viewModel.isVideoViewAdded = true

        // add
        val params = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
        contentView.addView(playerView, params)

        val frameParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
        playerView.controller?.timeBarContainer?.addView(timeBar, frameParams)

        viewModel.playMode = VideoPlayMode.MODE_FULL_SCREEN
    }

}

退出全屏播放

/* 退出全屏 */
    fun exitFullScreen() {
    // 横竖屏状态判断
    if (viewModel.playMode == VideoPlayMode.MODE_NORMAL) return
    // 显示ActionBar
    playerView.context.showActionBar()
    // 旋转屏幕
    playerView.context.activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
    // 将JJPlayerView从Activity的R.id.content移除,加入RecyclerView的ItemView下
    playerView.context.activity?.let {
        // remove
        val contentView = it.findViewById<ViewGroup>(android.R.id.content)
        contentView.removeView(playerView)
        playerView.controller?.timeBarContainer?.removeView(timeBar)

        // add
        viewModel.viewModelScope.launch {
            delay(100)
            addPlayerView()
        }

        viewModel.playMode = VideoPlayMode.MODE_NORMAL
    }
}

上面代码中涉及到的几个扩展方法,也一同贴出来:

//----------Activity----------
val Context.activity: Activity?
    get() {
        return when (this) {
            is Activity -> {
                this
            }
            is ContextWrapper -> {
                this.baseContext.activity
            }
            else -> {
                null
            }
        }
    }

val Context.appCompActivity: AppCompatActivity?
    get() {
        return when (this) {
            is AppCompatActivity -> {
                this
            }
            is ContextThemeWrapper -> {
                this.baseContext.appCompActivity
            }
            else -> {
                null
            }
        }
    }

//---------- ActionBar ----------
@SuppressLint("RestrictedApi")
fun Context.showActionBar() {
    this.appCompActivity?.supportActionBar?.let {
        it.setShowHideAnimationEnabled(false)
        it.show()
    }
    this.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
}

@SuppressLint("RestrictedApi")
fun Context.hideActionBar() {
    this.appCompActivity?.supportActionBar?.let {
        it.setShowHideAnimationEnabled(false)
        it.hide()
    }
    this.activity?.window?.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
}

至此ExoPlayer的高级自定义就到此为止了。

由于可以修改源码,所以进行高度自定义就变得可实现了。当然是在熟悉源码的前提下进行修改。

ExoPlayer在RecyclerView中的复用

复用

上面的实现效果中,我们点击RecyclerView不同的Item,都能播放视频,如果每个ItemView都有一个PlayerView那是非常不合适的。对PlayerView复用是一个非常合适的解决方案。

其实这个解决方案和全屏的方案也非常相似,就是将PlayerView在不同的Item中移除和加入。然后播放新的视频。

其中有一些细节需要处理,譬如播放的进度需要记录下来,下次再点击的时候从上次停止的地方进行播放。还譬如需要监听RecyclerView.OnChildAttachStateChangeListener,当执行onChildViewDetachedFromWindow时候,如果在播放需要将播放器停止。等等

有了思路,解决起来也就很简单了。这里不再贴代码了。

Jetpack Compose 和 ExoPlayer 结合可以创建流畅的视频播放体验。以下是一个简单的 Jetpack Compose 示例代码片段,演示了如何使用 ExoPlayer 在 Android 上播放网络视频: ```kotlin import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImagePainter import androidx.compose.ui.unit.dp import com.google.exoplayer2.DefaultLoadControl import com.google.exoplayer2.DefaultRenderersFactory import com.google.exoplayer2.ExoPlayer import com.google.exoplayer2.Player import com.google.exoplayer2.SimpleExoPlayer import com.google.exoplayer2.source.MediaSource import com.google.exoplayer2.upstream.HttpDataSource.Factory import com.google.android.exoplayer2.ext.mediasession.MediaSessionProvider @Composable fun VideoPlayer(playerState: PlayerState) { val exoPlayer = SimpleExoPlayer.Builder(context) .setMediaSource(MediaSource.Factory { /* 创建你的 MediaSource 实例 */ }) .setAudioAttributes(/* 设置音频属性 */) .setVideoSurfaceProvider { Surface(SurfaceConfig()) } .build() // 初始化并设置播放状态 exoPlayer.setPlayWhenReady(playerState.isPlaying) exoPlayer.seekTo(playerState.positionMs) LaunchedEffect(playerState) { // 更新播放进度 playerState.progressTracker?.let { progress -> playerState.player.addPeriodicCallback(progress::onProgressUpdate, 1000L) } // 处理播放完成、错误等事件 val listeners = mutableListOf<Player.EventListener>() listeners.add(PlayerEventListener { event -> when (event) { is Player.StateChange -> handleStateChanged(event.state) is Player.LoadError -> handleLoadError(event.error) // 其他事件... } }) playerState.player.addListener(listeners.toComposite()) // 播放控制 playerState.player.playWhenReady = playerState.isPlaying playerState.player.pauseWhenNotPlaying() } // 当播放暂停时显示暂停图标,播放时显示播放图标 Box( modifier = Modifier .align(Alignment.CenterVertically) .fillMaxSize() .clickable { if (!exoPlayer.isPlaying) exoPlayer.play() else exoPlayer.pause() }, painter = ImagePainter( painter = if (exoPlayer.isPlaying) PlayerView.PausedImagePainter else PlayerView.PlayingImagePainter, contentDescription = "Play/Pause button" ) ) } // 假设有一个 PlayerState 类来管理播放状态 data class PlayerState( var positionMs: Long = 0, var isPlaying: Boolean = false, val player: ExoPlayer, // 添加其他需要的状态和监听器 ) // ... 在合适的地方初始化 PlayerState,并将其传入 VideoPlayer 函数 ``` 请注意,这只是一个基础示例,实际应用你还需要处理更多的细节,如错误处理、加载进度更新、媒体源的构建、以及用户交互等。同时,`MediaSource.Factory` 需要你自己提供一个具体的工厂来创建 `MediaSource`,这里省略了。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值