> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kontext.so/llms.txt
> Use this file to discover all available pages before exploring further.

# 指南

> 第一个广告之外的配方：同时加载多个广告、你自己的竞价、无填充、播放器、下一集、观众、Compose、Java。

## 同时加载多个广告

要同时准备多种格式，并发地请求并加载它们。每种格式并行请求和渲染，某种格式没有广告不会影响其他格式：

```kotlin theme={null}
suspend fun prepare(format: AdFormat): VideoAd? = try {
    ads.format(format).also {
        it.load()
        it.listener = this
    }
} catch (e: VideoAdException) {
    null                                   // 这次该格式无填充
}

lifecycleScope.launch {
    val overlay = async { prepare(AdFormat.OVERLAY) }
    val pause = async { prepare(AdFormat.PAUSE) }
    val linear = async { prepare(AdFormat.LINEAR) }
    overlayAd = overlay.await()
    pauseAd = pause.await()
    linearAd = linear.await()
}
```

## 运行你自己的竞价

`VideoAd` 在渲染前就带着价格，所以你可以和其他广告网络比较后再决定：

```kotlin theme={null}
val ad = ads.format(AdFormat.PAUSE)
if (ad.price.value >= otherNetworkCpm) {
    ad.load()
    pauseAd = ad
} else {
    ad.reject(VideoAd.RejectReason.Outbid(winningPrice = otherNetworkCpm, winner = "Other network"))
}
```

`price.value` 是以 `price.currency` 计的 CPM。`price.precision` 说明它是广告主的成交价（`EXACT`）还是自有需求的预估（`ESTIMATED`）。

`reject` 立即告知服务器，服务器告诉广告主其胜出未产生曝光。另一个广告网络以价格胜出时用 `Outbid` 并带上胜出价格和广告网络，让我们能针对你的流量优化出价；其他任何原因用 `NotUsed`（默认）：时机过了、观众离开了。拒绝是可选的；你从未使用的广告会在过期或你调用 `destroy()` 时结算。

## 处理无填充

服务器没有该格式的广告时 `format()` 抛出 `VideoAdException.NoFill`。这不是失败，SDK 从不自行重试；在之后的时刻再问。

```kotlin theme={null}
try {
    pauseAd = ads.format(AdFormat.PAUSE)
} catch (e: VideoAdException.NoFill) {
    when (e.reason) {
        NoFillReason.NoAdMatch -> {}           // 此刻没有广告活动适合这一集或这位观众
        NoFillReason.GeoRestricted -> {}       // 观众所在的国家不投放广告
        else -> {}                             // FrequencyCap、ContentUnsafe、TemporaryError……
    }
}
```

对你的代码来说它们的意思都一样：这次没有广告，在下一个时刻再请求。如果想了解填充率低的原因，可以记录下原因。

## 把播放器交给 SDK

每次请求和事件都带有观众在这一集中的位置，叠加广告也会跟随播放：它只在内容播放时展示，观众一暂停就消失。剧集开始播放后，把播放器交给 SDK。

使用 Media3 播放器（`ExoPlayer`、`MediaController`）时，添加 `so.kontext:video-media3` 并直接交给 SDK；SDK 会读取它的时间并监听它的播放。播放器必须运行在主 looper 上：

```kotlin theme={null}
import so.kontext.video.media3.track

ads.track(exoPlayer)
```

使用其他播放器时，实现 `VideoAdsPlayer`：它的位置（毫秒），以及它是否在播放。缓冲和拖动进度都算作播放。

```kotlin theme={null}
class MyPlayerAdapter(player: MyPlayer) : VideoAdsPlayer {
    private val player = WeakReference(player)
    override val currentPositionMillis: Long get() = player.get()?.positionMillis ?: 0
    override val isPlaying: Boolean get() = player.get()?.let { it.state == State.PLAYING || it.state == State.BUFFERING } ?: false
}

ads.track(MyPlayerAdapter(myPlayer))
```

如果你的播放器有自己的监听器，实现 `addListener` 和 `removeListener`，并在每次变化时调用 `VideoAdsPlayer.Listener`（`onIsPlayingChanged`、`onSeek`、`onContentChanged`）：观众一暂停叠加广告就会结束。否则叠加广告在屏幕上期间 SDK 每秒检查 `isPlaying` 四次。SDK 持有适配器直到 `destroy()` 或下一次 `track`，所以让适配器弱持有你的播放器。

在这一集开始播放时调用 `track`，而不是在创建 `VideoAds` 时：在那之前播放器的时间属于上一集。在那之前 SDK 发送 0，这对预取来说是正确的。

## 让广告避开你的控件

叠加条幅位于容器的底部或顶部，暂停卡片位于顶部；SDK 让两者都避开与容器重叠的系统栏和屏幕刘海。如果你的播放器在那里绘制自己的控件（标题、进度条、按钮），把它们占用的空间作为 `insets` 传入：

```kotlin theme={null}
// 你的控制栏高 controlBar.height 像素，位于播放器底部。
overlayAd?.show(playerContainer, Insets.of(0, 0, 0, controlBar.height))
```

* `Insets` 是 `androidx.core.graphics.Insets`，单位为像素。它们叠加在系统栏和刘海之上：从它们的边缘量起，而不是从屏幕边缘。
* 广告展示期间它们保持不变，经过改变大小和旋转也是如此。广告在屏幕上时你的控件改变了大小，广告仍保留展示时的 insets。
* 广告仍须能以一半或更大的尺寸放下。insets 会占用空间，所以 `ContainerTooSmall` 包含它们。
* 全屏暂停广告忽略它们：它填满容器，它自己的控件会避开边缘。

## 下一集

当前一集播放时创建下一集的 `VideoAds`，预取它的广告，观众离开时销毁当前的。任意数量的实例可以同时存在；SDK 让整个应用同一时间最多一个广告在屏幕上。

```kotlin theme={null}
val next = VideoAds(nextEpisode)
lifecycleScope.launch {
    nextLinear = try { next.format(AdFormat.LINEAR).apply { load() } } catch (e: VideoAdException) { null }
}

// 观众继续看下一集时：
ads.destroy()
ads = next
ads.track(player)     // 播放器现在播放下一集
```

在剧集真正开始在播放器中播放时调用 `track`，而不是创建时：在那之前播放器的时间属于上一集。

## 观众是谁

把你知道的信息设置到 `KontextVideo` 上；下一次请求会携带它：

```kotlin theme={null}
KontextVideo.user = User { id = "user-1234"; keywords = listOf("romance", "drama") }
KontextVideo.variantId = "B"     // 与 Kontext 约定的 A/B 测试
```

> [!NOTE]
>
> 同意信息会自动读取：每次请求时，SDK 都会读取你的同意平台写入默认 `SharedPreferences` 的 IAB 键（TCF、GPP、US Privacy）。只有当 SDK 无法检测到某个信号时（你的应用没有这样的平台，或把信号保存在别处），才需要自行设置。你的同意平台写入的值仍然优先。
>
> ```kotlin theme={null}
> KontextVideo.regulatory = Regulatory {
>     gdpr = true
>     gdprConsent = "<TCF string>"
>     usPrivacy = "1YNN"
>     coppa = false
> }
> ```
>
> 见 [TCF 与 CMP](https://docs.kontext.so/zh/video/guides/tcf-cmp.md)。

## 跟随页面的生命周期

`VideoAds` 属于展示这一集的页面：页面打开时创建它，页面离开时 `destroy()` 它。

* 在 Activity 中，在 `onDestroy()` 里调用 `destroy()`；在 Fragment 中，在 `onDestroyView()` 里。
* 线性广告在 SDK 自己的 Activity 中运行，覆盖在你的 Activity 之上：你的 Activity 被停止而不是被销毁，广告结束会在它返回时送达你的监听器。
* 旋转时被重建的 Activity 也会运行 `onDestroy()`，从而运行 `destroy()`。[示例应用](https://docs.kontext.so/zh/video/sdk/kotlin/example.md)用 `android:configChanges` 让播放器 Activity 在旋转时保留。
* 广告持有它的监听器直到 `onAdEnded` 运行完毕，所以作为监听器的 Activity 在广告结束前一直可达。

## 在 Jetpack Compose 中使用

SDK 用 Android View 绘制叠加广告和暂停广告，Compose 通过 `AndroidView` 承载它们。线性广告无需额外操作：`show(activity)` 会把 SDK 的 Activity 启动在 Compose 当前显示的内容之上。叠加广告和暂停广告需要放进一个 `ViewGroup`；给它一个覆盖在播放器上的 `FrameLayout`：

```kotlin theme={null}
@Composable
fun PlayerScreen(content: Content, player: ExoPlayer) {
    val context = LocalContext.current
    val adArea = remember { FrameLayout(context) }          // 只创建一次，在整个页面期间保留
    val ads = remember(content.id) { VideoAds(content) }
    var pauseAd by remember { mutableStateOf<VideoAd?>(null) }

    DisposableEffect(ads) {
        ads.track(player)
        onDispose { ads.destroy() }
    }
    LaunchedEffect(ads) {
        pauseAd = try { ads.format(AdFormat.PAUSE).apply { load() } } catch (e: VideoAdException) { null }
    }

    Box(Modifier.fillMaxSize()) {
        AndroidView(factory = { PlayerView(it).apply { this.player = player } }, modifier = Modifier.fillMaxSize())
        AndroidView(factory = { adArea }, modifier = Modifier.fillMaxSize())
    }
}

// 到了时机：
try { pauseAd?.show(adArea) } catch (e: VideoAdException) { }
```

* 只创建一次 `FrameLayout` 并用 `remember` 保留：离开窗口的容器会把广告一起带走（`INTERRUPTED`）。
* 以剧集为键创建 `VideoAds`，并在 `DisposableEffect` 的 `onDispose` 中销毁它，这样页面或剧集离开时它也随之离开。
* 对于线性广告，把承载你的 composition 的 `Activity` 传给 `show(activity)`。

## 从 Java 调用

每个类型都有 builder，每个 `suspend` 调用都有在主线程上送达的回调版本：

```java theme={null}
KontextVideo.initialize(this, new Configuration.Builder("<your-publisher-token>").build());

VideoAds ads = new VideoAds(new Content.Builder("episode-1234", "en").title("Episode 1").build());
Media3.track(ads, exoPlayer);                       // so.kontext.video.media3.Media3

ads.format(AdFormat.PAUSE, new VideoAdsCallback<VideoAd>() {
    @Override public void onSuccess(VideoAd offer) {
        offer.load(new VideoAdsCallback<VideoAd>() {
            @Override public void onSuccess(VideoAd loaded) {
                loaded.setListener(new VideoAdListener() {     // 只实现你需要的
                    @Override public void onAdEnded(VideoAd ad, VideoAd.EndReason reason) {
                        if (reason == VideoAd.EndReason.RESUMED) player.play();
                    }
                });
                pauseAd = loaded;
            }
            @Override public void onError(VideoAdException error) { }
        });
    }
    @Override public void onError(VideoAdException error) { }   // NoFill……
});

// 观众暂停时：
try {
    pauseAd.show(playerContainer);
} catch (VideoAdException e) {
    // 没有展示
}
```

`show`、`reject` 和 `dismiss` 声明了 `VideoAdException`，所以 Java 要求 `try`。密封类型的值是单例：`NoFillReason.NoAdMatch.INSTANCE`、`VideoAd.RejectReason.NotUsed.INSTANCE`。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.