Skip to content

叙事植入 —— 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() 完成。

参数说明
adUnitIdstory_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)播放器发生 seekSDK 退化为按「位置跳变 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)
        }
    }
})

本文档仅供技术对接使用,请勿外传。