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。
开始代码实现之前,先考虑下如果要实现动态插播广告,需要传递给播放器的基本信息有哪些?
其实无论用什么方式实现动态插播广告,都需要这几个条件:
- 一部内容视频的所有广告点,也就是在什么时间插播广告
- 每个广告点插播广告的个数和url
- 播放广告的播放器
带着这个问题,我们试着用代码实现。创建一个类继承 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 更新广告数据是按照以下顺序进行的:
start() 函数里通过
AdPlaybackState(long... adGroupTimesUs)
创建 AdPlaybackState, 给 AdPlaybackState 添加广告组数量和每组广告的位置信息;第一个广告点的前几秒,更新 AdPlaybackState 在当前广告点的广告个数,以及第一个广告的 URI;
第一个广告播放完以后,更新 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 | } |
代码运行起来,广告播放完以后回退也不会重新播放了,整个电影和广告播放的体验非常流畅!