ExoPlayer实现无缝插播广告(上)

  上篇文章介绍了ExoPlayer-IMA 扩展实现无缝播放广告的基本原理。另一篇文章提到 ExoPlayer-IMA 扩展的使用场景有一个限制是它只能请求支持 VAST 协议的广告服务器,也就是说如果广告服务器返回数据是 VAST 格式的它才能派上用场,虽然谷歌等大多数的广告厂商支持 VAST 协议,那么如果遇到不支持 VAST 协议的广告服务器,该怎么办呢?这篇文章就介绍不使用 IMA 扩展,实现无缝播放广告的方法。

  想象一下有这样的场景:小A所在的公司是一家流媒体公司,公司靠广告营收,为了提高公司收入,公司播放的广告是单价较高的实时竞价广告(Real Time Biding),也就是播放一段视频内容前不知道要播放的广告是什么,这就要在每个广告点(cuePoint)请求公司的广告服务器,服务器返回给客户端广告的 url 地址,在客户端完成内容视频和广告的拼接。

  看过上篇文章,相信会对这个问题有一个大致的思路,参考 ExoPlayer-IMA 扩展的实现原理,我们也可以自己实现无缝插播广告。所以今天的主角依然是 AdsMediaSource 和它的构造参数之一 AdsLoader,从命名中能分辨出来 AdsMediaSource 和 AdsLoader 的关系,前者负责提供广告、内容视频的媒体资源,后者加载广告。ExoPlayer-IMA 扩展的源码实现就一个类 ImaAdsLoader,我们的实现思路同样是自定义一个类实现 AdsLoader 接口,创建 AdsMedisSource 的时候把自定义的 AdsLoader 传给 AdsMediaSource。

  开始代码实现之前,先考虑下如果要实现动态插播广告,需要传递给播放器的基本信息有哪些?

其实无论用什么方式实现动态插播广告,都需要这几个条件:

  1. 一部内容视频的所有广告点,也就是在什么时间插播广告
  2. 每个广告点插播广告的个数和url
  3. 播放广告的播放器

带着这个问题,我们试着用代码实现。创建一个类继承 AdsLoader 并实现它的接口,这里把它叫做 FancyAdsLoder:

1
class FancyAdsLoader : AdsLoader {
2
    
3
    /** 设置将要播放已加载广告的播放器
4
    * player 为 null 时用来删除对已设置的播放器的引用
5
    */
6
    override fun setPlayer(player: Player?) {
7
    }
8
9
  	/**
10
  	* 开始使用 AdsLoader 进行播放,该方法在主线程中被 AdsMediaSource 调用
11
  	*/
12
    override fun start(eventListener: AdsLoader.EventListener,
13
        adViewProvider: AdsLoader.AdViewProvider) {
14
    }
15
16
    /**  设置广告资源支持的视频协议,如 DASH、HLS、SS*/
17
    override fun setSupportedContentTypes(vararg contentTypes: Int) {
18
    }
19
		
20
  	/** 通知 AdsLoader 播放器无法准备给定的广告资源,
21
  	该方法的实现应该更新 AdPlaybackState
22
    */
23
    override fun handlePrepareError(
24
        adGroupIndex: Int,
25
        adIndexInAdGroup: Int,
26
        exception: IOException) {
27
    }
28
29
  	/** 停止使用广告加载器进行播放并注销事件监听器 */
30
    override fun stop() {
31
    }
32
    
33
  	/**  当 AdsLoader 不再被使用时,释放该AdsLoader */
34
    override fun release() {
35
    }
36
37
}

  查看 AdsMediaSource 的源码我们发现,AdsMediaSource 开始工作以后,会调用 AdsLoader.start() 函数请求广告数据,同时传递过来 AdsLoader.EventListener 和 AdsLoader.AdViewProvider 两个参数,其中 AdsLoader.EventListener 是 AdsMediaSource 和 AdsLoader 沟通的桥梁,AdsLoader 加载完广告以后,通过 EventListener.onAdPlaybackState(AdPlaybackState adPlaybackState) 函数更新 AdsMeidaSource 里的广告数据,其中 AdPlaybackState 代表了广告的详细信息,包括一共有多少组广告、每组广告的插入位置、一组广告和有几个广告和每个广告的 URI 等。在 FancyAdsLoader 里把 eventListener 保存为类的成员变量,这样每次广告数据需要更新的时候,调用 eventListener.onAdPlaybackState(AdPlaybackState adPlaybackState) 就能通知到 AdsMediaSource。做了基本分析以后,验证下效果怎么样,这里省略广告请求的过程,在 start() 方法里直接更新广告数据,分别在第 20s 和第 50s 插入两组广告,每组广告都包含两个广告:

1
class FancyAdsLoader: AdsLoader{
2
		private val adUri1 = "http://vfx.mtime.cn/Video/2019/03/19/mp4/190319212559089721.mp4"
3
    private val adUri2 = "https://www.w3school.com.cn/example/html5/mov_bbb.mp4"
4
    private val adUri3 = "https://media.w3.org/2010/05/sintel/trailer.mp4"
5
    private val adUri4 = "http://vjs.zencdn.net/v/oceans.mp4"
6
		private var mAdPlaybackState: AdPlaybackState? = null
7
		
8
		/**
9
  	* 开始使用 AdsLoader 进行播放,该方法在主线程中被 AdsMediaSource 调用
10
  	*/
11
    override fun start(eventListener: AdsLoader.EventListener,
12
        adViewProvider: AdsLoader.AdViewProvider) {
13
      mEventListener = eventListener
14
      if (adPlaybackState != null) {
15
            eventListener.onAdPlaybackState(adPlaybackState)
16
            return
17
        }
18
19
        adPlaybackState = AdPlaybackState(10 * C.MICROS_PER_SECOND, 
20
                                          20 * C.MICROS_PER_SECOND)
21
        adPlaybackState = adPlaybackState?.withAdCount(0, 2)
22
        adPlaybackState = adPlaybackState?.withAdUri(0, 0, Uri.parse(adUri1))
23
        adPlaybackState = adPlaybackState?.withAdUri(0, 1, Uri.parse(adUri2))
24
25
        adPlaybackState = adPlaybackState?.withAdCount(1, 2)
26
        adPlaybackState = adPlaybackState?.withAdUri(1, 0, Uri.parse(adUri1))
27
        adPlaybackState = adPlaybackState?.withAdUri(1, 1, Uri.parse(adUri2))
28
29
        eventListener.onAdPlaybackState(adPlaybackState)
30
    }
31
  
32
  	...
33
}

  运行起来,广告已经能在第10s 和第20s 精准播放了!内容视频切到广告,广告和广告之间的切换,广告再切回到内容视频跟播放同一个视频内容一样流畅,没有了视频切换之间的加载框和黑屏瞬间清爽了很多!

  实现主要的功能以后,还有些细节要处理,广告播放完以后,拖动进度条回退到之前的广告点,发现它会重复播放广告,这时就需要把一个播放完成的广告状态更新为已播放。AdPlaybackState 类提供了函数 withPlayedAd(int adGroupIndex, int adIndexInAdGroup) 来标记广告为已播放。问题是怎么知道广告播放完成了呢?我们先找下ExoPlayer 有没有提供相关的接口回调。

​ Player.EventListener 是 ExoPlayer对外提供的播放状态发生变化的监听,其中的函数onPositionDiscontinuity(@DiscontinuityReason int reason) 当播放器位置不连续且 TimeLine 没发生变化时会被调用,ExoPlayer 的位置不连续发生在当前的窗口或者片段发生变化,或者播放位置在当前播放的片段内发生跳动,详情请移步 ExoPlayer 源码。函数的参数 reason 有以下几种类型:

1
DISCONTINUITY_REASON_PERIOD_TRANSITION,
2
DISCONTINUITY_REASON_SEEK,
3
DISCONTINUITY_REASON_SEEK_ADJUSTMENT,
4
DISCONTINUITY_REASON_AD_INSERTION,
5
DISCONTINUITY_REASON_INTERNAL

  当一个广告播放完成以后,onPositionDiscontinuity 函数会被调用并且传递 reason 为DISCONTINUITY_REASON_AD_INSERTION的参数, 下面我们添加更新广告为已播放状态的代码,添加完以后代码是这个样子的:

1
class FancyAdsLoader: AdsLoader, Player.EventListener{
2
		private val adUri1 = "http://vfx.mtime.cn/Video/2019/03/19/mp4/190319212559089721.mp4"
3
    private val adUri2 = "https://www.w3school.com.cn/example/html5/mov_bbb.mp4"
4
    private val adUri3 = "https://media.w3.org/2010/05/sintel/trailer.mp4"
5
    private val adUri4 = "http://vjs.zencdn.net/v/oceans.mp4"
6
    private var mCurrentAdGroupIndex = 0
7
    private var mCurrentAdIndexInGroup = 0
8
		private var mAdPlaybackState: AdPlaybackState? = null
9
    private val mEventListener: AdsLoader.EventListener? = null
10
  	private var mPlayer: Player? = null
11
    private var mCuePointList = mutableListOf<Float>()
12
    private var fetchingAds = false
13
    private var mCurrentAdList = mutableListOf<String>()
14
		
15
  	/** 设置将要播放已加载广告的播放器
16
     * player 为 null 时用来删除对已设置的播放器的引用
17
     */
18
  	override fun setPlayer(player: Player?) {
19
        mPlayer = player
20
        mPlayer?.addListener(this)
21
    }
22
  
23
		/**
24
  	* 开始使用 AdsLoader 进行播放,该方法在主线程中被 AdsMediaSource 调用
25
  	*/
26
    override fun start(eventListener: AdsLoader.EventListener,
27
        adViewProvider: AdsLoader.AdViewProvider) {
28
      mEventListener = eventListener
29
      if (adPlaybackState != null) {
30
            eventListener.onAdPlaybackState(adPlaybackState)
31
            return
32
        }
33
34
        adPlaybackState = AdPlaybackState(10 * C.MICROS_PER_SECOND, 
35
                                          20 * C.MICROS_PER_SECOND)
36
        adPlaybackState = adPlaybackState?.withAdCount(0, 2)
37
        adPlaybackState = adPlaybackState?.withAdUri(0, 0, Uri.parse(adUri1))
38
        adPlaybackState = adPlaybackState?.withAdUri(0, 1, Uri.parse(adUri2))
39
40
        adPlaybackState = adPlaybackState?.withAdCount(1, 2)
41
        adPlaybackState = adPlaybackState?.withAdUri(1, 0, Uri.parse(adUri1))
42
        adPlaybackState = adPlaybackState?.withAdUri(1, 1, Uri.parse(adUri2))
43
44
        eventListener.onAdPlaybackState(adPlaybackState)
45
    }
46
  
47
  /** 播放器位置不连续的回调 */
48
  override fun onPositionDiscontinuity(reason: Int) {
49
        if (reason == Player.DISCONTINUITY_REASON_AD_INSERTION) {
50
            val playingAd = mPlayer?.isPlayingAd ?: false
51
            if (playingAd) {
52
                // 广告开始播放
53
                mCurrentAdGroupIndex = mPlayer?.currentAdGroupIndex ?: 0
54
                mCurrentAdIndexInGroup = mPlayer?.currentAdIndexInAdGroup ?: 0
55
               
56
                if (mCurrentAdIndexInGroup > 0) {
57
                    // 标记上一个广告已播放
58
                		adPlaybackState = adPlaybackState?.withPlayedAd(
59
                    mCurrentAdGroupIndex,
60
                    mCurrentAdIndexInGroup - 1)
61
                		updateAdPlaybackState()
62
                }
63
            }
64
        }
65
  }
66
  
67
  private fun updateAdPlaybackState() {
68
        mEventListener?.onAdPlaybackState(adPlaybackState)
69
  }
70
 
71
  	...
72
}

  运行代码看有没有解决回退会重复播放的问题,意料以外的事情发生了,第一个广告播放完,执行更新广告已播放的代码以后,还没等第二个广告开始,第一个广告又重新播了一遍,为什么会出现中情况呢,难道是哪个操作不正确?

  ExoPlayer 开发者文档对 AdsMediaSource 和 AdsMediaSource 的使用没有介绍,源码的代码注释有助于理解代码,但对解决这种问题帮助有限。如果想通过看源码查找这个问题的原因,需要把 AdsMediaSource, TimeLine 等相关代码全都看一遍才能理解,这种方法是根本的解决方法但效率不高。既然 ImaAdsLoader 能正常工作,那参考 ImaAdsLoader 怎么操作的会是一个不错的方法,于是通过在 ExoPlayer 工程里 给 ImaAdsLoader 加各种日志,发现上面的代码有一个地方和 ImaAdsLoader 的操作不一致,上面的示例代码在 start() 函数里把两组广告所有的广告信息都给了 AdsMediaSource, 而 ImaAdsLoader 更新广告数据是按照以下顺序进行的:

  1. start() 函数里通过 AdPlaybackState(long... adGroupTimesUs) 创建 AdPlaybackState, 给 AdPlaybackState 添加广告组数量和每组广告的位置信息;

  2. 第一个广告点的前几秒,更新 AdPlaybackState 在当前广告点的广告个数,以及第一个广告的 URI;

  3. 第一个广告播放完以后,更新 AdPlaybackState,用 withPlayedAd(int adGroupIndex, int adIndexInAdGroup) 标记第一个广告播放完成,如果该组广告还有下一个广告未播放,用 withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri) 更新下一个广告的 URI。

以此顺序类推往下进行…

​ 把示例代码也按照这个顺序修改后如下:

1
class FancyAdsLoader: AdsLoader, Player.EventListener{
2
		private val adUri1 = "http://vfx.mtime.cn/Video/2019/03/19/mp4/190319212559089721.mp4"
3
    private val adUri2 = "https://www.w3school.com.cn/example/html5/mov_bbb.mp4"
4
    private val adUri3 = "https://media.w3.org/2010/05/sintel/trailer.mp4"
5
    private val adUri4 = "http://vjs.zencdn.net/v/oceans.mp4"
6
    private var mCurrentAdGroupIndex = 0
7
    private var mCurrentAdIndexInGroup = 0
8
		private var mAdPlaybackState: AdPlaybackState? = null
9
    private val mEventListener: AdsLoader.EventListener? = null
10
  	private var mPlayer: Player? = null
11
  	private var mCuePointList = mutableListOf<Float>()
12
  
13
 	  private val mPlaybackListener = object : PlaybackListener {
14
        override fun onProgress(positionMs: Long, durationMs: Long) {
15
            // 播放器进度的更新,大约每秒钟一次
16
            maybeFetchAd(positionMs)
17
        }
18
    }
19
20
    init {
21
        setCuePoints()
22
    }
23
24
    private fun maybeFetchAd(positionMs: Long) {
25
        if (mPlayer?.isPlayingAd == true || mCuePointList.isEmpty()) {
26
            return
27
        }
28
29
        val positionS = positionMs / 1000
30
        val currentCuePoint = mCuePointList.first()
31
        if (currentCuePoint - positionS <= 5) {
32
            // 在广告点提前5秒钟取广告
33
            fetchingAds = true
34
            mCurrentAdList.clear()
35
36
            if (mNextAdGroupIndex == 0) {
37
                mCurrentAdList.add(adUri2)
38
                mCurrentAdList.add(adUri1)
39
            } else if (mNextAdGroupIndex == 1) {
40
                mCurrentAdList.add(adUri1)
41
                mCurrentAdList.add(adUri2)
42
            }
43
44
            fetchingAds = false
45
            mCuePointList.remove(currentCuePoint)
46
47
            // 拿到广告数据后更新广告数据
48
            adPlaybackState =
49
                adPlaybackState?.withAdCount(mNextAdGroupIndex, mCurrentAdList.size)
50
            adPlaybackState = adPlaybackState?.withAdUri(
51
                mNextAdGroupIndex,
52
                0,
53
                Uri.parse(mCurrentAdList.first())
54
            )
55
            updateAdPlaybackState()
56
        }
57
    }
58
59
    private fun setCuePoints() {
60
        mCuePointList = mutableListOf(10F, 20F)
61
    }
62
		
63
  	/** 设置将要播放已加载广告的播放器
64
     * player 为 null 时用来删除对已设置的播放器的引用
65
     */
66
  	override fun setPlayer(player: Player?) {
67
        mPlayer = player
68
        mPlayer?.addListener(this)
69
    }
70
  
71
		/**
72
  	* 开始使用 AdsLoader 进行播放,该方法在主线程中被 AdsMediaSource 调用
73
  	*/
74
    override fun start(eventListener: AdsLoader.EventListener,
75
        adViewProvider: AdsLoader.AdViewProvider) {
76
      mEventListener = eventListener
77
      if (adPlaybackState != null) {
78
            eventListener.onAdPlaybackState(adPlaybackState)
79
            return
80
        }
81
82
      val adGroupTimesUs = getAdGroupTimesUs(mCuePointList)
83
      adPlaybackState = AdPlaybackState(*adGroupTimesUs)
84
      updateAdPlaybackState()     
85
    }
86
  
87
    // 播放器状态回调接口 Player.eventListener 的实现
88
    override fun onTimelineChanged(timeline: Timeline, reason: Int) {
89
        handleProgress()
90
        if (timeline.isEmpty) { // The player is being reset or contains no media.
91
            return
92
        }
93
        Assertions.checkArgument(timeline.periodCount == 1)
94
        mTimeline = timeline
95
        val contentDurationUs = timeline.getPeriod(0, mPeriod).durationUs
96
        mContentDurationMs = C.usToMs(contentDurationUs)
97
        if (contentDurationUs != C.TIME_UNSET) {
98
            adPlaybackState = adPlaybackState?.withContentDurationUs(contentDurationUs)
99
        }
100
        onPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL)
101
    }
102
103
    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
104
        handleProgress()
105
    }
106
  
107
    /** 播放器位置不连续的回调 */
108
    override fun onPositionDiscontinuity(reason: Int) {
109
        handleProgress()
110
        if (reason == Player.DISCONTINUITY_REASON_AD_INSERTION) {
111
            val playingAd = mPlayer?.isPlayingAd ?: false
112
            if (playingAd) {
113
                // 开始播放广告
114
                mCurrentAdGroupIndex = mPlayer?.currentAdGroupIndex ?: 0
115
                mCurrentAdIndexInGroup = mPlayer?.currentAdIndexInAdGroup ?: 0
116
               
117
                if (mCurrentAdIndexInGroup == 0) {
118
                    // 播放第一个广告,更新下一个广告的uri
119
                    adPlaybackState = adPlaybackState?.withAdUri(
120
                        mCurrentAdGroupIndex,
121
                        mCurrentAdIndexInGroup + 1,
122
                        Uri.parse(mCurrentAdList[mCurrentAdIndexInGroup + 1])
123
                    )
124
                    updateAdPlaybackState()
125
                    return
126
                }
127
128
                // 不是第一个广告,标记上一个广告已播放
129
                adPlaybackState = adPlaybackState?.withPlayedAd(
130
                    mCurrentAdGroupIndex,
131
                    mCurrentAdIndexInGroup - 1
132
                )        
133
                updateAdPlaybackState()
134
135
                // 如果有下一个广告,更新广告的 uri
136
                val hasMoreAd = mCurrentAdIndexInGroup < mCurrentAdList.size - 1
137
                if (hasMoreAd) {
138
                    adPlaybackState = adPlaybackState?.withAdUri(
139
                        mCurrentAdGroupIndex,
140
                        mCurrentAdIndexInGroup + 1,
141
                        Uri.parse(mCurrentAdList[mCurrentAdIndexInGroup + 1])
142
                    )
143
                    updateAdPlaybackState()
144
                }
145
                return
146
            }
147
148
            // 广告切换到内容视频,标记上一个广告已播放
149
            adPlaybackState = adPlaybackState?.withPlayedAd(
150
                mCurrentAdGroupIndex,
151
                mCurrentAdIndexInGroup
152
            )
153
            updateAdPlaybackState()
154
            mNextAdGroupIndex = mCurrentAdGroupIndex + 1
155
            mNextAdIndexInGroup = 0
156
        }
157
    }
158
  
159
  private fun updateAdPlaybackState() {
160
        mEventListener?.onAdPlaybackState(adPlaybackState)
161
  }
162
  
163
  /** 增加播放器每秒更新进度的回调 */
164
  private fun handleProgress() {
165
        val playbackState = mPlayer?.playbackState
166
        val positionMs = mPlayer?.currentPosition ?: -1L
167
        val durationMs = mPlayer?.duration ?: -1L
168
        val bufferedPositionMs = mPlayer?.bufferedPosition ?: -1L
169
        val playerPositionValid =
170
            positionMs >= 0 && durationMs > 0 && bufferedPositionMs >= 0
171
        if (playerPositionValid) {
172
            mPlaybackListener.onProgress(positionMs, durationMs)
173
        }
174
        mHandler.removeCallbacksAndMessages(null)
175
        if (playbackState != ExoPlayer.STATE_IDLE && playbackState != 		  ExoPlayer.STATE_ENDED){
176
            if (mPlayer?.playWhenReady != true) {
177
                return
178
            } else {
179
                mHandler.postDelayed(mUpdateProgressAction, PROGRESS_UPDATE_FREQUENCY_MS)
180
            }
181
        }
182
    }
183
 
184
 	private fun getAdGroupTimesUs(cuePoints: List<Float>): LongArray {
185
        val count = cuePoints.size
186
        val adGroupTimesUs = LongArray(count)
187
        var adGroupIndex = 0
188
        for (i in 0 until count) {
189
            val cuePoint = cuePoints[i].toDouble()
190
            if (cuePoint == -1.0) {
191
                adGroupTimesUs[count - 1] = C.TIME_END_OF_SOURCE
192
            } else {
193
                adGroupTimesUs[adGroupIndex++] = (C.MICROS_PER_SECOND * cuePoint).toLong()
194
            }
195
        }
196
        Arrays.sort(adGroupTimesUs, 0, adGroupIndex)
197
        return adGroupTimesUs
198
  }
199
  	...
200
}
201
202
interface PlaybackListener {
203
    fun onProgress(positionMs: Long, durationMs: Long) {}
204
}

  代码运行起来,广告播放完以后回退也不会重新播放了,整个电影和广告播放的体验非常流畅!