主题
叙事植入 —— NovvyInsertAd
一集内自动串行插播多支广告,广告在宿主播放器视图内部展示(不起 Activity、无窗口转场)。每集一个实例,宿主只需 attach() 一次并在切集时告知哪一集在屏幕上,其余(请求、预加载、到点暂停、展示、关闭、恢复播放、请求下一支)全部由 SDK 完成。
宿主的进度条/点赞等 UI 可以继续浮在广告之上,无需隐藏。
默认路径使用
androidx.media3.exoplayer.ExoPlayer(SDK 已传递依赖 media3,见SDK 安装)。使用第三方播放器的宿主,见下方「非 ExoPlayer 宿主」小节。
对接流程
Episode starts(本集开始播放)
↓
setContentContext(...) // series 级信息(剧名等)
↓
NovvyInsertAd(adUnitId, episode) + attach(player, playerView) // 只绑定,不请求
↓
切到本集时 → setActive(true) // 从这里开始,SDK 自动跑
↓
onAdLoaded → onAdShown → onAdClosed // 每支广告一轮,自动重复
↓
onChainFinished // 本集广告结束
↓
页面回收 → destroy()接入代码
kotlin
// 每集一个实例;episode 传本实例代表的集数
val insertAd = NovvyInsertAd(adUnitId = "your_ad_unit_id", episode = 3)
// 绑定宿主的 ExoPlayer 与承载视频画面的容器(只绑定,不发请求)
insertAd.attach(exoPlayer, playerView, object : NovvyInsertAdListener {
override fun onAdLoaded(ad: NovvyInsertAd) { /* 一支广告已就绪 */ }
override fun onAdFailedToLoad(ad: NovvyInsertAd, error: NovvyError) { /* ... */ }
override fun onAdShown(ad: NovvyInsertAd) { /* 已展示(播放器已暂停) */ }
override fun onAdClosed(ad: NovvyInsertAd) { /* 已关闭(播放器已恢复) */ }
override fun onChainFinished(ad: NovvyInsertAd) { /* 本集广告全部结束 */ }
})
// 切集:只对新的一集调 true,旧的会自动停止
override fun onPageSelected(position: Int) {
adOfPage(position).setActive(true)
}
// 页面被回收时释放
insertAd.destroy()参数
attach() 只做绑定、不发请求,可在页面创建时就调用,不必等播放器 prepare() 完成。
| 参数 | 说明 |
|---|---|
adUnitId | story_placement 类型的广告位标识 |
episode | 本实例代表的集数,请显式传入 —— 覆盖 setContentContext(...) 的集数,剧名等 series 级信息仍取自上下文 |
player | 宿主的 ExoPlayer |
playerView | 承载视频画面的容器(ViewGroup),广告作为子 view 铺满它。传视频画面实际占据的那一层 —— 外面若还套了别的布局,不要传外层;用 androidx.media3.ui.PlayerView 的直接传它 |
listener | 可选,不需要回调时可不传 |
播放器由 SDK 控制
广告展示期间 SDK 会暂停宿主播放器、关闭后自动恢复,宿主无需自行 play() / pause()。两个例外:
- 剧尾广告关闭后保持暂停,由用户自己滑到下一集
- 宿主在广告展示期间
destroy()了实例,播放权交回宿主,需自行play()
宿主要求
| 要求 | 说明 |
|---|---|
| 播放器不要循环播放 | REPEAT_MODE_ALL/ONE 下播放器不会进入 STATE_ENDED,剧尾广告无法触发 |
切集时只对新的一集调 setActive(true) | 旧实例自动停止,无需手动配对 setActive(false);滑回上一集时广告会自动恢复 |
| 回收可视范围外的实例 | 照 RecyclerView 回收 ViewHolder 的方式 destroy() |
| 右上角留出安全区 | SDK 的关闭按钮在右上角 |
| 广告展示期间禁用自己的进度拖动与单击暂停 | 避免与 SDK 的暂停状态冲突 |
| 播放器在主线程创建 | 建议用 TextureView 承载视频而非 SurfaceView |
非 ExoPlayer 宿主 —— NovvyStoryPlayerAdapter
上面默认你手里是 ExoPlayer。使用第三方播放器(自研播放器、云播放器 SDK 等)的宿主,实现一个接口把自己的播放器交给 SDK,其余用法(attach() / setActive / destroy / 回调)完全一致,只是 attach() 的第一个参数换成适配器:
kotlin
class MyPlayerAdapter(private val player: MyPlayer) : NovvyStoryPlayerAdapter {
// currentPositionMs / isPlaying 继承自暂停卡的 NovvyPlayerAdapter,本接口再加四个成员
override val currentPositionMs: Long get() = player.position // 廉价读取
override val isPlaying: Boolean get() = player.isPlaying
override val durationMs: Long get() = player.duration // <= 0 表示未知/直播
override fun pause() = player.pause()
override fun play() = player.play()
override fun seekTo(positionMs: Long) = player.seekTo(positionMs)
}
val adapter = MyPlayerAdapter(myPlayer) // 一个播放器一个实例,可被整个 pager 共用
insertAd.attach(adapter, playerContainer, listener)把宿主自己知道的事告诉 SDK(三个方法任意线程可调,SDK 内部切主线程;pager 场景下调当前活跃实例即可,广播给全部实例也安全):
| 方法 | 何时调 | 不调的后果 |
|---|---|---|
notifySeek(fromMs, toMs) | 播放器发生 seek | SDK 退化为按「位置跳变 vs 墙钟」猜测,倾向于把可疑跳变判成 seek 并丢弃广告 |
notifyEnded() | 本集播放完毕 | 强烈建议接:片尾创意的真实信号。SDK 的兜底要求 durationMs 是真实值,直播流或未 prepare() 返回 0 的播放器会让片尾广告永远不出现,且没有任何报错 |
notifyMediaItemChanged() | 同一个播放器换了播放内容而没走切集(playlist 换集) | 位置归零会被当成向后 seek |
额外的宿主要求(上一节「宿主要求」表的六条仍然适用):
| 要求 | 说明 |
|---|---|
🔒 currentPositionMs / isPlaying / durationMs 必须是廉价的主线程读取 | 临界窗口内 SDK 以 ~16ms 一次的频率读它们。跨 JNI / IPC / 加锁的实现会拖累主线程:SDK 有降级保护(平均 >2ms 采样变疏并记 warning,>8ms 放弃密集采样并记 error),但代价是填充率——大部分插播点会被读到太晚而丢弃 |
🔒 currentPositionMs 的量化粒度必须 ≤ 100ms | 与上一条正交:那条问 getter 有多贵,这条问它有多粗。粒度为 q 的 getter 只在 q 的格子上给出位置,采样再密也没用;q = 300(按秒上报的封装、部分云播放器 SDK)约三分之二的插播点会被丢弃。SDK 只能观测并记 warning,救不了 |
pause() / play() / seekTo() 必须无条件执行 | 包括广告正在显示时。宿主 UI 浮在广告之上,进度条常见手势是「按下暂停、松手播放」,那个 play() 不被撤销的话,剧集人声会在广告底下响 |
container 不要绘不透明背景 | 宿主的定格帧要透出来,这是「接得像剧情」的前提 |
⚠️ 播放器若是 SurfaceView 且调过 setZOrderOnTop(true) | 广告会被压在视频下面、完全不工作,且这条路 SDK 检测不到。推荐 TextureView |
isPlaying 缓冲时最好报 false | 报 true 不会出错(SDK 用位置停滞兜底),但每次缓冲会多空转最多 250ms |
| 不要 loop 播放 | 播放器循环播放就永远播不到结尾,片尾创意不会触发 |
精度:这条路径没有 ExoPlayer 的帧级信号,定格精度来自「越接近插播点采样越密」。观察到的跨越晚于插播点超过 100ms 时,SDK 按「宿主 seek 跨过去了」处理并丢弃这支广告。这条路的保证是要么准、要么不出现,不是「永远准」——失败模式是少一次曝光,而不是把创意定格在错误的帧上。
事件回调
NovvyInsertAdListener 方法均有空实现默认值,按需重写;回调统一在主线程触发。一集内多支广告串行,因此 Loaded / Shown / Closed 会重复触发:
| 回调 | 触发时机 |
|---|---|
onAdLoaded(ad) | 一支广告预载完成,等待到点(每支各一次) |
onAdFailedToLoad(ad, error) | 加载失败(链随即结束),error 取值见错误类型 |
onAdShown(ad) | 到点展示、播放器已暂停、曝光上报完成 |
onAdClosed(ad) | 广告关闭、播放器已恢复(剧尾广告关闭后保持暂停) |
onChainFinished(ad) | 本集广告全部结束(终态,之后再调 setActive(true) 不会重启) |
切集不产生回调:滑走时不会回调
onAdClosed,滑回时也不会再次回调onAdShown—— 恢复的是同一支广告,曝光只上报一次。
onAdFailedToLoad只在本集为当前活跃集时触发。
完整示例(ViewPager2 竖滑短剧)
四个接入点分别落在 ViewHolder 的绑定、翻页回调、ViewHolder 回收上:
kotlin
class EpisodeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
// 视频画面实际占据的那一层容器
private val playerContainer: FrameLayout = itemView.findViewById(R.id.player_container)
private var player: ExoPlayer? = null
var insertAd: NovvyInsertAd? = null
private set
fun bind(episode: Int) {
val exo = ExoPlayer.Builder(itemView.context).build().also { player = it }
// …宿主自己的播放器初始化:setVideoTextureView / setMediaItem / prepare
// 注意不要设 REPEAT_MODE_ALL / ONE
// ① 每集一个实例,绑定即可,不会发请求
insertAd = NovvyInsertAd(adUnitId = "your_ad_unit_id", episode = episode).apply {
attach(exo, playerContainer, object : NovvyInsertAdListener {
override fun onAdShown(ad: NovvyInsertAd) { /* 可在此禁用自己的拖动/单击暂停 */ }
override fun onAdClosed(ad: NovvyInsertAd) { /* 恢复自己的手势 */ }
})
}
}
// ③ ViewHolder 被回收时释放
fun recycle() {
insertAd?.destroy()
insertAd = null
player?.release()
player = null
}
}
class EpisodeAdapter : RecyclerView.Adapter<EpisodeViewHolder>() {
override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
NovvySDK.setContentContext(NovvyContentContext(seriesName = "剧名"))
holder.bind(episode = position + 1)
}
override fun onViewRecycled(holder: EpisodeViewHolder) = holder.recycle()
}
// ② 切集:只对新的一集调 setActive(true),旧的会自动停止
private val pagerRecycler get() = viewPager.getChildAt(0) as RecyclerView
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
pagerRecycler.post {
val holder = pagerRecycler.findViewHolderForAdapterPosition(position)
(holder as? EpisodeViewHolder)?.insertAd?.setActive(true)
}
}
})