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 | } | 
代码运行起来,广告播放完以后回退也不会重新播放了,整个电影和广告播放的体验非常流畅!