Skip to content

叙事植入 —— NovvyInsertAd ​

一集内自动串行插播多支广告,广告在宿主播放器视图内部展示(不起 UIViewController、无窗口转场)。每集一个实例,宿主只需 attach() 一次并在切集时告知哪一集在屏幕上,其余(请求、预加载、到点暂停、展示、关闭、恢复播放、请求下一支)全部由 SDK 完成。

宿主的进度条/点赞等 UI 可以继续浮在广告之上,无需隐藏。

两条播放器接入路径

默认路径使用原生 AVFoundation.AVPlayer,即 attach(player:playerView:)。使用第三方播放器(TXVodPlayer / AliPlayer / IJKPlayer / 自研包装)的宿主,改传 NovvyStoryPlayerAdapter,见下方「第三方播放器接入」小节。

对接流程 ​

Episode starts(本集开始播放)
        ↓
setContentContext(...)                                          // series 级信息(剧名等)
        ↓
NovvyInsertAd(adUnitId:episode:) + attach(player:playerView:)   // 只绑定,不请求
        ↓
切到本集时  →  setActive(true)                                   // 从这里开始,SDK 自动跑
        ↓
insertAdDidLoad  →  insertAdDidShow  →  insertAdDidClose        // 每支广告一轮,自动重复
        ↓
insertAdChainDidFinish                                          // 本集广告结束
        ↓
页面回收  →  destroy()

接入代码 ​

swift
// 每集一个实例;episode 传本实例代表的集数
let insertAd = NovvyInsertAd(adUnitId: "your_ad_unit_id", episode: 3)
insertAd.delegate = self

// 绑定宿主的 AVPlayer 与承载视频画面的容器(只绑定,不发请求)
insertAd.attach(player: avPlayer, playerView: playerView)

// MARK: - NovvyInsertAdDelegate
func insertAdDidLoad(_ ad: NovvyInsertAd) { /* 一支广告已就绪 */ }
func insertAd(_ ad: NovvyInsertAd, didFailToLoadWithError error: NovvyError) { /* ... */ }
func insertAdDidShow(_ ad: NovvyInsertAd) { /* 已展示(播放器已暂停) */ }
func insertAdDidClose(_ ad: NovvyInsertAd) { /* 已关闭(播放器已恢复) */ }
func insertAdChainDidFinish(_ ad: NovvyInsertAd) { /* 本集广告全部结束 */ }

// 切集:只对新的一集调 true,旧的会自动停止(无需对旧实例配对 setActive(false))
insertAd.setActive(true)

// 页面被回收时释放
insertAd.destroy()

参数 ​

attach() 只做绑定、不发请求,可在页面创建时就调用,不必等播放器加载完成。

参数说明
adUnitIdstory_placement 类型的广告位标识
episode本实例代表的集数,请显式传入 —— 覆盖 setContentContext(...) 的集数,剧名等 series 级信息仍取自上下文
player宿主的 AVPlayer
playerView承载视频画面的 UIView,广告作为子视图铺满它。传视频画面实际占据的那一层 —— 外面若还套了别的视图,不要传外层;接了 AVPlayerLayer 的那个 view 直接传它
delegate可选,不需要回调时可不设

播放器由 SDK 控制 ​

广告展示期间 SDK 会暂停宿主播放器、关闭后自动恢复,宿主无需自行 play() / pause()。两个例外:

  • 剧尾广告关闭后保持暂停,由用户自己滑到下一集
  • 宿主在广告展示期间 destroy() 了实例,播放权交回宿主,需自行 play()

宿主要求 ​

要求说明
播放器不要循环播放AVPlayer.actionAtItemEnd 保持 .pause(默认值)。用 AVPlayerLooper 或 .none + 手动重播时播放器不会走到片尾,剧尾广告无法触发
切集时只对新的一集调 setActive(true)旧实例自动停止;滑回上一集时广告会自动恢复。手动配对 setActive(false) 也安全(SwiftUI 里 .onChange { setActive(newValue == index) } 这种对称写法即可),不会弄丢 standby 里保活的邻集广告
回收可视范围外的实例照 UICollectionView / UIPageViewController 回收 cell 的方式 destroy()。至少保留相邻一集 —— 否则跨集回看(1→2→1 恢复上一集广告)会静默失效,SDK 只在 os_log 打 warning
右上角留出安全区SDK 的关闭药丸在右上角,约 30×30pt
广告展示期间禁用自己的进度拖动与单击暂停避免与 SDK 的暂停状态冲突。SDK 内部有位置锁兜底(拖走会弹回、被 play() 会重新按停),但双方仍应配合
播放器在主线程创建AVFoundation 主线程亲和的操作要用到

第三方播放器接入 —— NovvyStoryPlayerAdapter ​

上面默认你手里是 AVPlayer。使用第三方播放器的宿主实现这个协议,SDK 就能在它上面跑完全相同的广告链。宿主只负责「如实回答 + 老实执行」,采样、定时、到点暂停、播完兜底、广告期间的锁纠正全在 SDK 内部。

swift
// 六个成员:currentPositionMs / isPlaying 继承自暂停卡的 NovvyPlayerAdapter,本协议再加四个
final class MyStoryPlayerAdapter: NovvyStoryPlayerAdapter {
    let player: TXVodPlayer

    // ⚠️ 同步读,不是「上一次进度回调缓存的值」—— 见下面宿主要求第 3、4 条
    var currentPositionMs: Int64 { Int64(player.currentPlaybackTime * 1000) }
    var isPlaying: Bool { player.isPlaying() }
    var durationMs: Int64 { Int64(player.duration * 1000) }

    func pause() { player.pause() }
    func play() { player.resume() }
    func seekTo(positionMs: Int64) { player.seek(Float(positionMs) / 1000) }
}

let adapter = MyStoryPlayerAdapter(player: myPlayer)   // ⚠️ SDK 只弱引用,宿主自己强持有到 destroy()
insertAd.attach(adapter: adapter, playerView: playerView)

同一个 adapter 传给 pager 里多个 NovvyInsertAd 是鼓励的主用法 —— 宿主只有一个播放器,SDK 保证同一时刻只有一条链在驱动它。

把宿主自己知道的事告诉 SDK(三个方法任意线程可调,第三方播放器的回调普遍来自它们自己的原生线程,SDK 内部切主线程。pager 场景下调当前 setActive(true) 的那个实例即可;广播给全部实例同样安全,非活跃实例会自行丢弃):

方法什么时候调不接的后果
notifySeek(fromMs:toMs:)宿主播放器发生 seekSDK 退化为「位置突变」的启发式判断,会被 getter 的量化粒度糊掉边界
notifyEnded()本集播放完毕强烈建议接:片尾创意(ad_playback_time = -1)的真实信号。兜底要求 durationMs 是真实值,直播 / 尚未 prepare 完返回 0 的播放器会让片尾广告永远不出现,且没有任何报错
notifyMediaItemChanged()换片源(切集、换清晰度)而没走 setActive(false)位置归零会被当成向后 seek

宿主要求(上一节「宿主要求」表的通用条目仍然适用):

要求说明
🔒 硬性准入:currentPositionMs / isPlaying / durationMs 必须是廉价的主线程读取临界窗口内 SDK 会以一帧一次的频率读它们。跨线程加锁 / IPC 的实现会拖累主线程:SDK 有降级保护(平均 >2ms 放宽采样并打 warning,平均 >8ms 时会关掉临界窗口采样),但代价是填充率——大部分插入点会被判为迟到丢弃,表现是这个宿主的叙事植入填充率异常偏低
🔒 硬性准入:currentPositionMs 的量化粒度 ≤ 100ms与上一条正交:那条问 getter 有多贵,这条问它有多粗,SDK 的帧级采样对它完全帮不上忙。⚠️ 最自然的错误实现是「返回上一次播放进度回调里缓存的值」 —— 第三方播放器的进度回调普遍 0.5–1s 一次,即粒度 500–1000ms,十倍于准入线。正确写法是调播放器的同步位置 API(如 TXVodPlayer.currentPlaybackTime),都是廉价的即时读取
pause() / play() / seekTo() 无条件执行,包括广告正在显示时宿主 UI 浮在广告之上,进度条常见手势是「按下暂停、松手播放」。那个 play() 不被撤销的话,剧集人声会在广告底下响 —— 画面被盖住,声音是唯一症状
playerView 不绘不透明背景宿主的定格帧要透出来,这是「接得像剧情」的前提(「传视频画面实际占据的那一层」仍然适用)
isPlaying 缓冲时最好报 false报 true 不会出错(SDK 用位置停滞兜底),但每次缓冲会多空转最多 250ms。⚠️ 播完那一刻必须转 false,否则没接 notifyEnded() 时片尾创意的兜底会晚一步
不要 loop 播放播不到结尾,片尾创意永远不触发

两条接入路径怎么选

attach(player:playerView:)attach(adapter:playerView:)
定格精度由构造保证 —— 触发点判定在媒体时钟上(addBoundaryTimeObserver),免疫倍速与主线程卡顿SDK 主动采样宿主位置逼近,取决于宿主 getter 的成本与粒度
宿主要写的代码传两个对象6 个成员 + 建议接 3 个上报

拿得到 AVPlayer 的宿主请走默认路径。 adapter 路是「拿不到 player 时的等价路径」,不是首选。另外:attach 时 adapter 已被释放(传了个临时对象)会被忽略并回调 insertAd(_:didFailToLoadWithError:)(.invalidRequest)。

精度:这条路径没有 AVPlayer 的时钟信号,定格精度来自「越接近插播点采样越密」。观察到的跨越晚于插播点超过 100ms 时,SDK 按「宿主 seek 跨过去了」处理并丢弃这支广告。这条路的保证是要么准、要么不出现,不是「永远准」——失败模式是少一次曝光,而不是把创意定格在错误的帧上。

事件回调 ​

一集内多支广告串行,因此 Load/Show/Close 会重复触发。回调统一在主线程触发。

回调触发时机
insertAdDidLoad(ad)一支广告预载完成,等待到点(每支各一次)
insertAd(ad, didFailToLoadWithError:)加载失败(链随即结束,同时回调 insertAdChainDidFinish),error 取值见错误类型
insertAdDidShow(ad)到点展示、播放器已暂停(不代表曝光已上报,见回调与曝光)
insertAdDidClose(ad)广告关闭、播放器已恢复(剧尾广告关闭后保持暂停)
insertAdChainDidFinish(ad)本集广告全部结束(终态,之后再调 setActive(true) 不会重启)

切集不产生回调:滑走时不会回调 insertAdDidClose,滑回时也不会再次回调 insertAdDidShow —— 恢复的是同一支广告,曝光只上报一次。

insertAd(ad, didFailToLoadWithError:) 只在本集为当前活跃集时触发。

完整示例(SwiftUI 竖滑短剧) ​

三个接入点分别落在页面创建、切集回调、页面消失上:

swift
@MainActor final class EpisodeHost: NovvyInsertAdDelegate {
    let player = AVPlayer()
    private(set) var insertAd: NovvyInsertAd?

    // ① 每集一个实例,绑定即可,不会发请求
    func setUp(episode: Int, playerView: UIView) {
        player.actionAtItemEnd = .pause          // 注意不要循环播放
        // …宿主自己的播放器初始化:replaceCurrentItem / AVPlayerLayer 接到 playerView

        NovvySDK.shared.setContentContext(NovvyContentContext(seriesName: "剧名"))

        let ad = NovvyInsertAd(adUnitId: "your_ad_unit_id", episode: episode)
        ad.delegate = self
        ad.attach(player: player, playerView: playerView)   // playerView = 视频画面实际占据的那一层
        insertAd = ad
    }

    // ③ 页面被回收时释放
    func tearDown() {
        insertAd?.destroy()
        insertAd = nil
    }

    func insertAdDidLoad(_ ad: NovvyInsertAd) { }
    func insertAd(_ ad: NovvyInsertAd, didFailToLoadWithError error: NovvyError) { }
    func insertAdDidShow(_ ad: NovvyInsertAd) { /* 可在此禁用自己的拖动/单击暂停 */ }
    func insertAdDidClose(_ ad: NovvyInsertAd) { /* 恢复自己的手势 */ }
}

struct EpisodePage: View {
    let episode: Int
    let isActive: Bool
    @State private var host = EpisodeHost()

    var body: some View {
        PlayerContainer(host: host, episode: episode)   // 内部调 host.setUp(episode:playerView:)
            .ignoresSafeArea()
            // ② 切集:只对新的一集调 setActive(true),旧的会自动停止
            .onChange(of: isActive) { _, newValue in
                if newValue { host.insertAd?.setActive(true) }
            }
            .onDisappear { host.tearDown() }
    }
}

(完整可运行示例见 NovvyAdsDemo/InsertAdTestView.swift。)

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