主题
叙事植入 —— NovvyInsertAd
单集之内的自驱动串行广告链(story_placement,custom.format = 7)。素材首帧是为短剧的某一 瞬间专门制作的,与该帧画面贴合,所以交接读起来是「剧里插了一段」而不是「跳走看广告」。广告在 宿主自己的播放器容器内部展示——没有 Activity 转场、没有全屏切换。
与其他六位最大的不同:没有 load()。attach() 只绑定播放器、不发请求;链从 setActive(true) 起步,所以为离屏剧集预建的实例是零成本的。
本位全部策略都在原生 SDK 里
谁是 active、谁进 standby、插入点是否过期、链何时终止——JS 侧一概不推导。宿主只需要做两件事:把播放器状态喂进来,以及执行 SDK 发回来的播放器命令。
对接流程
每集一个 NovvyInsertAd 实例(相邻集也建,成本为零)
↓
挂载 <NovvyInsertAdView>(与播放器同时挂,不要等 ready)
↓ 视图就绪 → 自动 attach(binding):绑定播放器,不发请求
↓
这一集滑到屏上 → setActive(true) // 唯一的策略信号
↓
onAdLoaded // 一支素材预载完成(链里每支都回调一次)
↓
到达插入点 → SDK 暂停播放器、揭示广告
↓
onAdShowed // 广告可见,曝光在此之后按可见性判据上报
↓
用户关闭(或自动关闭)→ SDK 恢复播放
↓
onAdClosed // 链继续,等下一个插入点
↓
onChainFinished // 终态:本集不会再有广告
↓
离开可视范围 ±1 集之外 → destroy() // 唯一真销毁路径构造函数:new NovvyInsertAd(adUnitId: string, episodeNumber?: number | null, episodeUrl?: string | null)
episodeNumber 显式传入而不是读 setContentContext:竖滑 pager 会预建相邻页,读全局值会把请求 标记成「当前正在播的那一集」。
类成员
| 成员 | 说明 |
|---|---|
attach(binding: NovvyInsertPlayerBinding) | 绑定播放器,不发请求(通常由 <NovvyInsertAdView> 自动调用)。原生侧幂等:视图重建后再次 attach 会接管既有链,不会开第二条 |
setActive(active: boolean) | 告诉 SDK 这一集是否在屏上。只在新在屏那一集调 true,永不配对 false(见下方必守五条第 2 条)。可在视图存在之前调用,平台侧会缓冲到广告对象建好 |
notifySeek(fromMs, toMs) | 上报一次精确 seek。可选但建议接:没有它,SDK 只能用「位置增量 vs 墙钟」的启发式判断,且该启发式偏保守——宁可丢掉一支广告,也不把它展示在错误的帧上 |
notifyMediaItemChanged() | 播放器被指向了别的内容(播放列表式换集)却没走 setActive(false) 时上报 |
detach() | 只解绑视图,不结束链(通常由组件卸载时自动调用)。链继续持有它在 SDK 注册表里的槽位与已预载的素材 |
destroy() | 唯一真销毁路径。释放链与原生实例 |
onAdLoaded | () => void,一支素材预载完成。链里每支都会回调,不是一次性的 |
onAdFailedToLoad(error) | (error: string) => void,一次加载失败。不会自动 destroy——SDK 会自行终止链并补发 onChainFinished |
onAdShowed | () => void,广告揭示(播放器此刻已被 SDK 暂停) |
onAdClosed | () => void,一支广告关闭(播放器已恢复)。链还在继续,这不是终态 |
onChainFinished | () => void,终态:本集不会再请求广告,之后再 setActive(true) 也不会重启 |
NovvyInsertAd.viewType | 'NovvyInsertAdView'(静态常量) |
播放器接缝 NovvyInsertPlayerBinding
Pull 模型:SDK 需要时读三个 getter,宿主只要保证它们能随时答出最新值即可,不必主动推。
| 成员 | 说明 |
|---|---|
currentPositionMs | number(getter),当前播放位置(毫秒) |
isPlaying | boolean(getter),是否正在播放(非暂停/缓冲) |
durationMs | number(getter),总时长。返回 0 则剧尾创意永远不会触发 |
pause() | 暂停宿主播放 |
play() | 恢复宿主播放 |
seekTo(positionMs) | 把宿主播放位置移到 positionMs |
⚠️
pause/play/seekTo在广告展示期间也必须照常执行。 宿主自有 UI 仍浮在广告之上,进度条 的常规手势是「按下暂停、松手播放」;那下松手若不被撤销,剧集音轨会在广告底下响——画面被广告盖 住了,声音是唯一症状,所以这条最容易破、也最难自查。
宿主必守五条
- 不要循环播放(
repeat={false})。循环播放器永远不会播完,剧尾创意无法触发。 - 只对新在屏那一集调
setActive(true),上一集自动站下,不要手动配对setActive(false)。 认领动作本身就会让上一集站下,而这也正是「上一集的广告被保活、滑回去还能接着看」的来源;手动 配对false会把 standby 槽位打掉。 - 广告视图必须与播放器同时挂载,不要挡在 ready 之后。
attach决定这一集何时向 SDK 认领槽位,if (!ready) return <Spinner/>会把认领整整推迟一个视频initialize()的时长,这期间上一集仍是 active 链。 NovvyInsertAdView与NovvyInsertAd同生共死,且相邻一集(±1 页)建议留着。 视图没了而实例还是 active 链时,之后加载的创意会挂进离屏容器——不可见、无曝光,而 SDK 仍会在 插入点暂停播放器。- 右上角留空给关闭药丸,广告展示期间禁用自有的拖动与点击暂停。
完整示例(竖滑 + react-native-video)
tsx
function EpisodePage({ episode, isActive }: { episode: Episode; isActive: boolean }) {
const videoRef = useRef<VideoRef>(null);
// Pull 模型:getter 要答最新值,所以用 ref 而不是 state
const positionMs = useRef(0);
const durationMs = useRef(0);
const playing = useRef(false);
const binding = useMemo<NovvyInsertPlayerBinding>(() => ({
get currentPositionMs() { return positionMs.current; },
get isPlaying() { return playing.current; },
get durationMs() { return durationMs.current; },
pause: () => videoRef.current?.pause(), // 展示期间也要照常执行
play: () => videoRef.current?.resume(),
seekTo: (ms) => videoRef.current?.seek(ms / 1000),
}), []);
const ad = useMemo(() => new NovvyInsertAd(AD_UNIT, episode.number), [episode.number]);
useEffect(() => {
ad.onAdShowed = () => {};
ad.onAdClosed = () => {};
ad.onChainFinished = () => {};
return () => ad.destroy(); // 第 4 条:页面卸载才 destroy
}, [ad]);
useEffect(() => {
if (isActive) ad.setActive(true); // 第 2 条:只 claim,不配对 false
}, [ad, isActive]);
return (
<View style={{ height: SCREEN_HEIGHT }}>
<Video
ref={videoRef}
source={{ uri: episode.uri }}
style={StyleSheet.absoluteFill}
repeat={false} // 第 1 条
paused={!isActive}
onLoad={({ duration }) => { durationMs.current = Math.round(duration * 1000); }}
onProgress={({ currentTime }) => { positionMs.current = Math.round(currentTime * 1000); }}
onPlaybackStateChanged={({ isPlaying }) => { playing.current = isPlaying; }}
onSeek={({ currentTime, seekTime }) =>
ad.notifySeek(Math.round(currentTime * 1000), Math.round(seekTime * 1000))
}
/>
{/* 第 3 条:无条件挂载,不要等播放器 ready */}
<NovvyInsertAdView ad={ad} binding={binding} />
</View>
);
}完整可运行版本见 example/StoryDemoScreen.tsx。
事件回调
与其他类型都不同,因为它是一条链而不是一支广告:
| 回调 | 触发时机 |
|---|---|
onAdLoaded | 一支素材预载完成。链里每支都回调一次,不是一次性的「就绪」转换 |
onAdFailedToLoad(error) | 一次加载失败。不会自动 destroy —— SDK 自行终止链并补发 onChainFinished;error 取值见错误类型 |
onAdShowed | 广告揭示(不代表曝光已上报,见回调与曝光) |
onAdDismissed / onAdClosed | 链里的一支广告关闭;链继续,这不是终态 |
onChainFinished | 终态:本集不会再请求广告,之后再 setActive(true) 也不重启 |
切集不产生回调。 滑走不会回调
onAdClosed,滑回来也不会再回调onAdShowed—— 恢复的是同一支广告,曝光只上报一次。上一集只是被 SDK 挪进 standby 槽位,不是关闭。
回调统一在 JS 主线程 触发。