# Android SDK 与原生 Media3

下载 Android 播放器，体验画面内控制、可拖动播放列表、可保存的随机／循环设置及离线续播。了解如何通过 DRM-X 或原生 Media3 接入受保护的播放。

**自由选择播放器，共用同一套许可证服务。**可使用 DRM-X 6.0 Android SDK 按示例集成，也可保留原生 Media3 播放器。两者都连接到需要身份认证的后端和 Widevine。Android SDK 在兼容的 API 23+ 设备上支持在线播放及持久离线 VOD，覆盖手机、平板、电视及嵌入式 Android 系统。 

## 试试看 Android 播放器[#](#try-android-app)



**下载、安装，然后点击 Play。**免费评估应用使用公开演示视频并自动登录。无需自行构建 APK 或输入 DRM-X 凭据，即可试用。 

[下载 Android 示例 APK](https://docs.drm-x.com/downloads/android/1.0.0-preview.12/drmx-android-player-1.0.0-preview.12.apk)



**1.0.0-preview.12** · 7.02 MB · Android 6.0+ (API 23+) · DRM-X 6.0 Player · 更新 2026-09-14


- 在您的Android 手机上打开此页面并下载APK 。
- 打开下载的文件。如果 Android 提示，请允许当前浏览器或文件管理器安装应用，然后点击 **Install**。
- 打开 **DRM-X 6.0 Player**，保持联网，点击视频的 **Play** 按钮。只有点击 Play 时，应用才会请求在线播放许可证。
- 打开 **Settings** 选择画质、音轨、字幕或播放速度。旋转手机后可继续全屏播放。
- 点击 **Download** 下载当前视频，或点击 **Download playlist** 下载所有 VOD 项。下载选择许可证及设备支持的最高档位：L1 最高 1080p，L3 最高 480p。**Play offline** 打开已完成下载的本地列表，支持上一项、下一项、随机、循环和自动播放；**Go online** 返回完整列表。联网时点击 **Renew** 可续期所选许可证。



已排队的媒体下载会在后台继续。如果 Android 在准备许可证或轨道时终止应用，请联网后重新打开，完成剩余内容的准备。已有下载会保留。离线播放只使用缓存媒体；未下载完成或许可证已过期的内容，需要先完成下载或续期。



Widevine Provisioning 失败（Media3 6002）时，现可使用需要身份认证的演示服务器中继访问 Google 官方服务。可正常工作的 L3 CDM 可获得 SD 密钥。设备缺少可用 Widevine 实现时，需要修复设备或固件；许可证无法补充缺失的 DRM 能力。



公开示例 APK 已签名，供评估使用。下方 AAR 和完整 SDK 仍通过符合资格的 Console 账户提供。生产应用应使用自己独立的签名密钥及后端登录。

APK校验和USB安装

SHA-256：

```
8fa044b82c0abab7ce2a987f349524fd7ad1e78f3c897771f4764cb2aa521180
```

使用Android平台工具及授权的USB设备:

```
adb install -r drmx-android-player-1.0.0-preview.12.apk
```

直接安装此 APK 升级现有官方示例，无需卸载。相同签名密钥和应用 ID 可保留下载、离线许可证、播放进度及偏好。如果自行构建使用不同签名密钥，请使用另一个应用 ID，让两个应用共存并保留官方示例的离线许可证。





## Android 播放器 preview.12 更新内容[#](#latest-player-update)



2026 年 9 月 14 日发布。修复了下载后播放时，Widevine 许可证已过期或剩余不超过一分钟却意外请求新许可证的问题。该问题可能表现为 Media3 错误 `6004`、联网时的 HTTP 错误，或断网时的 DNS 错误。



播放器现在从本地恢复已下载许可证。有效许可证保留剩余播放时长；过期时提示 **Connect and tap Renew**。有效期和保护规则仍由设备执行。网络操作失败的诊断信息包含数值型 HTTP 状态，不暴露请求头、URL 或许可证响应。


- 在已安装的官方应用上直接升级到 preview.12，保留现有应用数据和下载。
- WayMaker 或其他已下载视频需要续期时，请联网，选择该视频，然后点击 **Renew**。
- 等待显示 **License renewed. Ready to play offline.**，再选择 **Play offline**。已下载的加密视频会保留。



本版保留 preview.11 的控件和播放列表功能。公开评估应用及内嵌播放器版本为 preview.12；通过 Console 单独分发的 SDK 仍为 preview.10 / bundle 20260910.8。





## Android 播放器 preview.11 更新内容[#](#player-preview11)



2026 年 9 月 14 日发布。此评估应用包含最新播放器控件和播放列表改进，与 Android 和 Apple 播放器保持一致的交互方式。


- **视频内控件：**包含居中的 Play/Pause、多视频列表的 Previous 和 Next、CC、Settings 及全屏按钮。播放时两秒后隐藏，点击画面即可重新显示。 
- **字幕与播放设置：**CC 可显示或隐藏可用字幕；Settings 可选择字幕语言、音轨、播放速度和获准画质。离线模式只提供已下载的轨道。 
- **拖动排序：**拖动播放列表项的手柄可调整位置。顺序在重启后保留，也应用于已下载列表，调整时不会中断当前视频。点击手柄可使用便于无障碍操作的 Move up / Move down。 
- **保存随机和循环设置：**选中模式保持高亮，在线和离线播放均在重启后保留。随机和循环都关闭时，按保存顺序播放，并在最后一项结束后停止。播放列表菜单已移除“Play next”。 
- **从上次位置继续：**再次在线或离线播放时，会恢复该视频保存的 VOD 进度。进度按当前用户保存在此设备上，不跨设备同步。 



播放器改进已在 Samsung S24 Ultra 上验证，包括播放时拖动排序、重启后保留顺序、恢复播放进度，以及同时关闭 Wi-Fi 和移动数据后的下载内容播放。离线观看仍需完整下载和有效、未过期的离线许可证。



本次更新针对公开评估 APK。通过 Console 单独分发的 SDK 仍为 `1.0.0-preview.10`，包版本为 `20260910.8`；下方集成说明对应该 SDK 包。





## 1. 选择集成路径[#](#choose-your-path)



[路径 A · 快速开始**DRM-X 播放器**

使用 DrmXPlayer、手机示例及离线管理器，并接入自己的登录和业务规则。

](#android)[路径 B · 保留现有界面**Media3 + DRM-X 辅助工具**

保留已有播放器及下载实现，由 DrmXOfflineManager 管理持久许可证。

](#existing-media3)[路径 C · 仅原生集成**不使用 AAR 的 Media3 集成**

自行实现相同的后端接口契约和原生许可证生命周期，无需依赖 DRM-X 播放器。

](#configure-media3) 

[在控制台 → 集成中下载 Android SDK ZIP 或 AAR](https://6.drm-x.com/console/integrations)。订阅有效的**专业版（$499/月）和企业版**组织可以下载。试用账户和标准版账户不能下载 SDK。本文档及其中的原生 Media3 示例公开提供；实际播放仍需要具有访问资格的账户、已发布内容和兼容设备。



当前预览版本：`1.0.0-preview.10` · 客户 SDK 包：`20260910.8` · Media3 `1.11.0`。ZIP 包含本地 Maven 仓库、Gradle wrapper、带注释的 README、手机／电视／原生集成示例、位于 `apks/` 的可安装手机示例 APK，以及离线集成说明。包内不含客户凭据或受保护的视频。



## 2. 准备内容和后端[#](#before-you-code)


- 在您的 DRM-X Environment 中打包加密并发布兼容 Widevine 的 DASH 视频，然后准确复制其 **Published Content ID**。
- 在服务器上配置 Environment ID、Site Key 和 Access Key。可从[可下载的 PHP 集成示例](https://developer.drm-x.com/php-sample/)开始。
- 实现用户登录及观看权限检查。应用使用短期用户 Token 调用后端，再由后端确定内容、策略和可信 URL。
- 先在支持 Widevine 的实体设备上验证在线播放。确认在线播放接口契约正常后，再接入离线许可证获取。



加密视频和 DRM 许可证通过不同路径传输

CDN 负责传输加密视频。您的后端检查观看权限，DRM 许可证服务则授权兼容设备解密播放。

- 01**存储 / CDN**

向播放器传输加密媒体。

- 02**您的后端**

确认用户有权观看后，向 DRM-X 请求播放授权。

- 03**DRM-X**

验证播放授权并处理 DRM 许可证请求。

- 04**播放器 + 设备**

使用设备的 DRM 系统获取许可证，并播放加密媒体。

媒体分发与许可证交付分别进行。只有您的后端持有 DRM-X 服务器凭据。 

| 更改此值 | 配置位置 | 含义 | 
| --- | --- | --- |

| sessionEndpoint | Android | 您的 HTTPS 播放会话接口，不是原始许可证服务器 URL。 | 

| offlineEndpoint | Android，仅离线播放 | 您的获取、续期及释放授权接口。 | 

| contentId | Android 请求; 后端验证 | 已发布保护的Content ID观看者有权访问. | 

| 客户访问令牌 | 登录后应用内存 | 短期有效的用户 Token，不得使用 Site Key 或 Access Key。 | 

| Environment ID、Site Key、Access Key | 仅保存在服务器端的凭据配置中 | DRM-X 账户凭据。不得放入 APK、HTML 或版本控制系统。 | 

| 策略及可信 Manifest URL | 您的后端 | 业务观看权限及内容包映射由服务器决定，不接受应用直接指定。 | 



## 3. 安装和运行 SDK[#](#install-sdk)



在 Android Studio 中打开解压后的 ZIP 根文件夹。使用 JDK 17、Android SDK 36 和 Android Gradle Plugin 9.2.0；随附的 wrapper 使用 Gradle 9.4.1。最低设备 API 级别为 23，下载构建依赖需要联网。选择 `samples.phone` 并运行。初始配置使用公共演示后端和示例内容；先按照登录和在线播放流程测试，再切换到自己的后端。



```
# Windows
gradlew.bat :samples:phone:assembleDebug
# macOS / Linux
sh gradlew :samples:phone:assembleDebug
```



对于已有的应用程序, 请将 ZIP 的 `maven/` 文件夹复制到您的工程根 。 在 `settings.gradle.kts` 中 :



```
dependencyResolutionManagement {
    repositories {
        maven { url = uri(rootDir.resolve("maven")) }
        google()
        mavenCentral()
    }
}
```



在您的应用程序模块 `build.gradle.kts` 中 :



```
dependencies {
    implementation("com.haihaisoft.drmx:drmx-universal-player:1.0.0-preview.10")
}
```

使用独立 AAR 的安装方式

将 AAR 放入应用模块的 `libs/`。独立 AAR 不会自动解析传递依赖，因此需要自行加入三个匹配版本的 Media3 依赖。不要同时安装两种 SDK 形式。

```
implementation(files("libs/drmx-universal-player-1.0.0-preview.10.aar"))
implementation("androidx.media3:media3-exoplayer:1.11.0")
implementation("androidx.media3:media3-exoplayer-dash:1.11.0")
implementation("androidx.media3:media3-ui:1.11.0")
```



在 `AndroidManifest.xml` 中允许联网，并为这个最小示例禁用备份。生产应用如需备份，应明确排除绑定到设备的离线许可证元数据。



```
<uses-permission android:name="android.permission.INTERNET" />
<application android:allowBackup="false" android:usesCleartextTraffic="false" ... >
```



在手机示例的 `MainActivity.kt` 中搜索 `drmx.sessionEndpoint`、`drmx.offlineEndpoint`、`drmx.contentId` 和 `customer.loginEndpoint`。修改标出的备用配置，或在应用登录后通过 Intent extras 传入。在运行时传递 `customer.accessToken`。ZIP 中的 README 说明了各参数及需要替换的演示接口。



## 使用 AAR[#](#build-apk-from-aar) 构建测试APK



此示例使用完整 SDK ZIP 中可运行的手机项目，并明确引用独立 AAR。已有 Android 应用也可采用同样方式。


- 下载并解压[完整 Android SDK ZIP](https://6.drm-x.com/console/integrations)，在 Android Studio 中打开其根目录。
- 将 `libs/drmx-universal-player-1.0.0-preview.10.aar` 保留在解压根目录。使用 JDK 17、Android SDK 36 及随附的 Gradle wrapper。
- 在`samples/phone/build.gradle.kts` 中, 将 DRM-X Maven 依赖性 替换为 `dependencies` 中的以下条目。 保留其余的构建文件 。



```
implementation(files(rootProject.file("libs/drmx-universal-player-1.0.0-preview.10.aar")))
implementation("androidx.media3:media3-exoplayer:1.11.0")
implementation("androidx.media3:media3-exoplayer-dash:1.11.0")
implementation("androidx.media3:media3-ui:1.11.0")
```



删除 `implementation("com.haihaisoft.drmx:drmx-universal-player:1.0.0-preview.10")`，避免重复引入同一个 SDK。独立 AAR 需添加上文列出的三个 Media3 依赖。


- 若需让已下载的官方示例与自行构建的测试版共存，将手机模块的 `applicationId` 改为 `com.example.drmxtest`。随附示例的 namespace 保持不变。
- 构建 APK 后，将其安装到通过 USB 连接且已授权 USB 调试的 Android 设备。



```
# Windows, from the extracted SDK root
gradlew.bat :samples:phone:assembleDebug
adb install -r samples/phone/build/outputs/apk/debug/phone-debug.apk

# macOS / Linux
sh gradlew :samples:phone:assembleDebug
adb install -r samples/phone/build/outputs/apk/debug/phone-debug.apk
```



打开测试应用并点击 Play。随附的手机源码初始使用同一个公开演示服务。测试自有内容时，在 `MainActivity.kt` 中替换标出的会话、离线授权和登录接口及状态为 Published 的 Content ID，并在运行时提供自己用户的短期认证信息。Site Key 和 Access Key 保留在服务器上。



这些小型控件由 AAR 中的 `DrmXPlayerView` 提供。随附 Activity 演示了点击 Play 后授权、旋转适配、下载归属、离线播放及续期。接入自己的应用时，可参考下方[控件集成示例](#player-controls)。





## 4. 服务器请求与默认策略[#](#server-request)



Android 应用向您的后端请求播放会话。后端认证用户、检查观看权限，将 Content ID 映射到已发布媒体后调用 DRM-X。完整的在线播放接口契约见[播放会话 API](https://docs.drm-x.com/zh-Hans/api-reference/playback-sessions)。



```
{
  "contentId": "YOUR-PUBLISHED-CONTENT-ID",
  "contentType": "vod",
  "drmSystem": "widevine",
  "playbackMode": "streaming",
  "platformCapabilities": {
    "clientPlatform": "android",
    "drmSystems": ["widevine"], "manifestTypes": ["dash"],
    "maximumHeight": 1080, "secureDecoder": false, "persistentState": false
  }
}
```



上面的保守能力信息并未声称设备支持 Widevine L1。请使用 SDK 能力辅助工具或自己的检测方案评估实体设备。客户端能力只用于选择兼容路径，不会授予更高画质权限。



PHP 样本默认为`multi-tier-standard` 用于在线播放 。 当您的加密输出包使用一个密钥时, 请选择它所记录的单键策略。 您的政策必须匹配加密输出包的密钥模型和查看者的权利 。 更改服务器配置或它的`DRMX_LICENSE_POLICY_TEMPLATE` 环境设置, 而不是播放器 中的查询参数 。 见[ 许可证政策](https://docs.drm-x.com/zh-Hans/license-service/policies) 。



**离线播放需要独立的持久许可证策略。**设定 `playback_policy.persistent` 在服务器签发的策略中设为 true，并选择许可证、租赁、播放及续期时长。示例分别使用 7 天、3 天、48 小时和 7 天；这些只是示例业务规则，不代表已获得制片方批准，也不是平台统一默认值。返回与策略一致的元数据，最终有效期由 Widevine 许可证决定。



## 5. 路径A:通过 DrmXPlayer 播放[#](#android)



在 Activity 中创建 `PlayerView`，使用安全窗口，并在登录后调用 `prepare`。封装层负责交换会话、配置 Widevine、应用返回的最大视频高度限制，并在停止时释放流播放预留名额。所需类型来自 `com.haihaisoft.drmx.player` 和 AndroidX Media3。



```
val view = PlayerView(this)
setContentView(view)
DrmXPlayer.protectWindow(window)
val player = DrmXPlayer(this, view)
val capabilities = WidevineCapabilities.playbackCapabilities(
    clientPlatform = "android",
    displayMetrics = resources.displayMetrics,
    applicationVersion = "1.0",
)
player.prepare(
    endpoint = "https://customer.example/api/drmx/playback-session",
    contentId = "YOUR-PUBLISHED-CONTENT-ID",
    contentType = "vod", // use live only for a published live title
    capabilities = capabilities,
    customerAuthenticationHeaders = mapOf("Authorization" to "Bearer $userToken"),
)
// Keep player as an Activity/Fragment-owned field in your actual application.
// Stop when your playback lifecycle ends; close when the owner is destroyed.
// player.stop()
// player.close()
```



注册 `DrmXPlayerListener` 以显示授权中、就绪、播放中及错误状态。显示安全错误码，不要输出许可证 Token 或响应体。完整生命周期、全屏控件及下载队列可参考随附手机示例。



## 播放器控件与旋转时连续播放[#](#player-controls)



点击视频中的 **Play** 才开始授权。打开页面或 Settings 不会获取在线播放许可证。暂停与恢复、更改设置或旋转手机均保留当前会话。



![Samsung S24 Ultra 上的 DRM-X Android 播放器设置，包含 Quality、Audio track、Subtitles/CC、Playback speed 和 Sleep timer。](https://docs.drm-x.com/assets/android-player-settings-preview4.png)Samsung S24 Ultra 上的实际设置界面。点击选项可查看子菜单；音轨菜单显示完整语言名称。可选画质和语言取决于内容、许可证及设备。 

| 控件 | 顾客能做的 | 
| --- | --- |

| 画质 | 选择 Auto，或 Manifest 中设备支持且许可证允许的分辨率。不可用选项会说明许可证、设备或 DRM 限制；Auto 同样遵守这些限制。 | 

| 音频轨道 | 选择加密输出包提供的任何支持的音频语言 。 | 

| Subtitles/CC | 选择字幕语言或 Off。存在受支持的字幕时会显示 CC 按钮，可直接开启或关闭字幕。 | 

| 字幕外观 | 可调整字体、文字颜色与大小、背景和字幕窗口的颜色及不透明度。偏好设置保存在设备上。 | 

| 播放速度 / 睡眠定时器 | 支持 0.25–2× 播放速度，也可设置在指定时间后自动暂停。 | 

| 进度跳转 / 全屏 | 拖动进度条、前后跳转 10 秒，或双击画面左右两侧。旋转到横屏时无需重新开始播放。 | 


画面下方三个按钮分别为 **Download**（下载供离线使用）、**Play offline** 和 **Renew**（续期离线许可证）。示例下载一个获准视频档位，以及受支持的音轨和字幕；离线菜单只显示已下载媒体。续期需要联网。



信息区旁的 **Copy log** 可将最近错误复制为文本，便于提交支持请求，无需截图。报告包含报告 ID、操作、SDK/Android/设备版本、数值型 CDM/Media3 错误、选定堆栈帧、连接/下载/播放状态及重复次数。不包含凭据、媒体 URL、密钥、Key-set ID、许可证数据或完整 CDM 转储。



示例最多保留 32 份本地报告（64 KB），保存五小时。联网且已登录时，向配置的客户后端上传待发送错误，频率不超过每 30 秒一次。公开演示服务器保存五小时的警告和错误，合并重复事件，将文件限制为 512 KB，并每分钟清理。常规进度及成功播放不记入日志。自定义集成必须明确提供自己的同源 `drmx.diagnosticsEndpoint`；诊断失败不会中断播放。



**离线许可证正在使用中？**Preview.5 修复了 S24 报告 CDM 368、提示“License loaded in different session”的问题。再次点击 Play offline 会恢复已有播放器。Renew 会先释放其 DRM 会话，再续期已保存的许可证。下载及持久许可证均保留，无需清除应用数据。 

```
val player = DrmXPlayer(this)
val controls = DrmXPlayerView(this)
controls.bind(player) // Does not acquire a session or license.
controls.onPlayRequested = {
    player.prepare(endpoint = sessionEndpoint, contentId = contentId,
        capabilities = capabilities, customerAuthenticationHeaders = userHeaders)
}
controls.onFullScreenRequested = { /* resize the existing player view */ }
player.listener = object : DrmXPlayerListener {
    override fun onAuthorizing(contentId: String) { controls.setPreparing(true) }
    override fun onReady(info: DrmXPlaybackInfo) { controls.setPreparing(false) }
    override fun onError(error: PlaybackSessionException) { controls.setPreparing(false) }
}
// Keep protectWindow(window) and the secure video surface.
// At final owner destruction: controls.unbind(); player.close()
```



随附手机 Manifest 声明了方向和屏幕尺寸变化处理；其 `onConfigurationChanged` 会调整同一个 Activity 和播放器。适配时请保留这两部分。若宿主重新创建 Activity，应保留播放控制对象并重新挂载视图。进程被终止与屏幕旋转不同，需要正常的状态恢复。4K/8K 选项要求内容、许可证权限和设备/DRM 能力均匹配，最终仍由 CDM 执行限制。



使用自定义播放器时，检查、续期或释放同一个持久许可证前，先停止播放并释放正在使用的离线 DRM 会话。SDK 的 `player.stopForOfflineLicenseOperation { /* call manager here */ }` 会等待 Media3 播放队列释放媒体；普通 `stop()` 只是异步排入该操作。该许可证正在播放时，不要另行调用 `checkLicense()`，因为播放过程本身会验证 CDM 状态。示例演示了完整交接流程，并在续期期间禁用重叠操作。



## 6. 获得许可证和下载媒体[#](#offline-download)



准备离线播放

完整的下载和有效且绑定到设备的许可证，二者缺一不可。

- 01**下载加密媒体**

联网时下载视频，以及所需的音轨和字幕。

- 02**获取持久许可证**

联网时获取绑定到设备的 Widevine 许可证，并私有保存其不透明的 Key-set ID。

- 03**检查是否就绪**

确认下载已完成，且许可证仍然有效。

- 04**离线播放**

读取缓存媒体并恢复本地许可证。续期和释放需要联网。

离线可播放 = 完整的加密媒体 + 有效且绑定到设备的许可证，二者缺一不可。 

```
val offlineManager = DrmXOfflineManager(applicationContext)
val capabilities = WidevineCapabilities.offlinePlaybackCapabilities(
    clientPlatform = "android", // android-tv or android-automotive where applicable
    displayMetrics = resources.displayMetrics,
    applicationVersion = "1.0",
)
offlineManager.acquireLicense(
    endpoint = offlineEndpoint,
    contentId = contentId,
    capabilities = capabilities,
    customerAuthenticationHeaders = customerHeaders,
) { result ->
    result.onSuccess { stored ->
        // Next: select tracks <= stored.maximumHeight and queue their download.
        // Acquisition alone does not download the video.
    }.onFailure { /* show a safe retry/error state */ }
}
```



SDK 保存不透明的 Widevine Key-set ID 及非机密元数据，许可证由 CDM 持有。SDK 不会持久保存 DRM License Tokens、原始密钥、Challenge 或许可证响应字节。备份时请排除 `com.haihaisoft.drmx.offline.licenses.v1.xml`。



为应用创建统一管理的 Media3 `SimpleCache`、下载管理器和数据库。对已配置许可证的 MediaItem 使用 `DownloadHelper`，并在 `maximumHeight` 范围内选择视频。在 Media3 1.11 中，准备完成的回调为 `onPrepared(helper, tracksInfoUpdated)`。手机示例的 `enqueueDownload` 和 `OfflineTestRuntime` 提供可运行实现。



```
// Inside DownloadHelper's prepared callback, after selecting licensed video:
helper.addAudioLanguagesToSelection("en", "zh-Hans", "zh")
helper.addTextLanguagesToSelection(true, "en", "zh-Hans", "zh")
val selected = helper.getDownloadRequest(offlineLicenseId, byteArrayOf())
val request = offlineManager.createDownloadRequest(contentId, selected.streamKeys)
downloadManager.addDownload(request)
helper.release()
```



下载计划在离线模式中提供的所有语言。调用 `createDownloadRequest` 时若不指定 Stream keys，会下载全部轨道，对自适应视频通常造成浪费。显示下载进度并等待 `Download.STATE_COMPLETED`。生产环境的后台传输需接入 Media3 `DownloadService`、存储限制，以及 Android 前台服务和通知要求；演示队列在应用进程内运行。



**多密钥离线内容：**Media3 为一个播放项恢复一个 Key-set ID。对应的持久许可证必须包含所选视频、音频及其他加密轨道需要的全部密钥。请使用真实内容包和服务器策略验证；开启多个会话不会自动合并独立的离线许可证。



## 7. 路径B:用现有的 Media3 播放器[#](#existing-media3)



读取已完成的下载并使用其中准确的 Stream keys。下载和播放实现必须共用相同的缓存位置及键映射。以下示例明确禁用上游网络访问，以验证严格离线播放：



```
val cacheOnly = CacheDataSource.Factory()
    .setCache(downloadCache)
    .setUpstreamDataSourceFactory(null)
val mediaSourceFactory = DefaultMediaSourceFactory(this)
    .setDataSourceFactory(cacheOnly)
val nativePlayer = ExoPlayer.Builder(this)
    .setMediaSourceFactory(mediaSourceFactory)
    .build()
val completed = downloadIndex.getDownload(offlineLicenseId)
require(completed?.state == Download.STATE_COMPLETED)
val playback = offlineManager.createPlayback(contentId, streamKeys = completed!!.request.streamKeys)
nativePlayer.setMediaItem(playback.mediaItem)
nativePlayer.prepare()
nativePlayer.play()
// Release nativePlayer when the owner is destroyed.
```



路径 A 使用同样的离线流程时，以此 `mediaSourceFactory` 构建 `DrmXPlayer`，再调用 `player.prepareOffline(playback)`。常规恢复使用本地 CDM 许可证，不请求新许可证。严格断网验收时，缓存缺失应报错，不得静默切换到互联网。



## 8. 路径 C：原生 Media3，无需 DRM-X AAR[#](#configure-media3)



您可以不使用 DrmXPlayer 或 DrmXOfflineManager。添加步骤 3 的三个 Media3 依赖，并实现同一个客户后端接口契约。使用 `session.manifestUrl`（不要使用 `session.manifest.url`）、响应返回的许可证 URL 及受限授权请求头。应用返回的最大视频高度，并在停止、更换内容、结束、失败或销毁时释放在线播放会话。



下方完整原生 Activity 随本 SDK 发布，并已编译为 `samples:native`。它处理 HTTPS、限制响应读取大小、后台授权、失效响应及并发播放名额释放。请替换标出的接口和 Content ID，并提供自己的登录 Token。此示例是集成起点；生产应用还需处理认证刷新、账户切换及页面导航生命周期。

完整的原生在线播放 Activity（Kotlin，无 DRM-X 依赖）

```
package com.haihaisoft.drmx.samples.nativeplayer

import android.app.Activity
import android.os.Bundle
import android.view.WindowManager
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean

/** Complete native streaming example. No DRM-X AAR or server credentials. */
@UnstableApi
class MainActivity : Activity() {
    private lateinit var player: ExoPlayer
    private lateinit var status: TextView
    private val worker = Executors.newSingleThreadExecutor()
    private val closed = AtomicBoolean(false)
    private var activeSession: JSONObject? = null
    private var requesting = false
    private var generation = 0
    // CUSTOMIZE: use YOUR authenticated customer backend and Published Content ID.
    // The sample can receive these after your application's login via Intent extras.
    // Never replace the app login token with a DRM-X Site Key or Access Key.
    private val endpoint get() = intent.getStringExtra("drmx.sessionEndpoint")
        ?: "https://customer.example/api/drmx/playback-session"
    private val contentId get() = intent.getStringExtra("drmx.contentId") ?: "YOUR-PUBLISHED-CONTENT-ID"
    private val customerToken get() = intent.getStringExtra("customer.accessToken").orEmpty()

    override fun onCreate(state: Bundle?) {
        super.onCreate(state)
        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
        val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; fitsSystemWindows = true; setPadding(24, 24, 24, 24) }
        status = TextView(this).apply { text = "Native Media3 · connect your backend, then Play"; textSize = 16f }
        val view = PlayerView(this)
        player = ExoPlayer.Builder(this).build(); view.player = player
        root.addView(status)
        root.addView(view, LinearLayout.LayoutParams(-1, 0, 1f))
        root.addView(Button(this).apply { text = "Play protected video"; setOnClickListener { startPlayback() } })
        root.addView(Button(this).apply { text = "Stop & release session"; setOnClickListener { stopPlayback() } })
        setContentView(root)
        player.addListener(object : Player.Listener {
            override fun onPlayerError(error: PlaybackException) { status.text = "Playback error: ${error.errorCodeName}"; releaseSession() }
            override fun onPlaybackStateChanged(state: Int) { if (state == Player.STATE_ENDED) releaseSession() }
        })
    }

    private fun startPlayback() {
        if (requesting) return
        stopPlayback(); requesting = true; status.text = "Authorizing…"
        val attempt = generation
        val request = JSONObject().put("contentId", contentId).put("contentType", "vod")
            .put("drmSystem", "widevine").put("playbackMode", "streaming")
            .put("platformCapabilities", JSONObject().put("clientPlatform", "android")
                .put("drmSystems", org.json.JSONArray(listOf("widevine")))
                .put("manifestTypes", org.json.JSONArray(listOf("dash")))
                .put("maximumHeight", 1080).put("secureDecoder", false).put("persistentState", false))
        val headers = if (customerToken.isBlank()) emptyMap() else mapOf("Authorization" to "Bearer $customerToken")
        worker.execute {
            val result = runCatching { JSONObject(post(endpoint, request.toString(), headers)) }
            runOnUiThread {
                requesting = false
                result.onSuccess { session ->
                    if (closed.get() || attempt != generation) { workerRelease(session); return@onSuccess }
                    // Keep release information even if contract validation fails.
                    activeSession = session
                    runCatching {
                        require(session.getInt("contractVersion") == 1 && session.getString("contentId") == contentId)
                        require(session.getString("drmSystem") == "widevine" && session.getString("playbackMode") == "streaming")
                        val drm = session.getJSONObject("drm")
                        require(drm.getString("system") == "widevine")
                        val maximumHeight = session.getJSONObject("policy").getInt("maximumHeight")
                        require(maximumHeight in 1..4320)
                        val mediaItem = MediaItem.Builder().setUri(https(session.getString("manifestUrl")))
                            .setMimeType("application/dash+xml")
                            .setDrmConfiguration(MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
                                .setLicenseUri(https(drm.getString("licenseServerUrl")))
                                .setForceDefaultLicenseUri(true).setLicenseRequestHeaders(authorization(drm))
                                .setMultiSession(false).build()).build()
                        player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
                            .setMaxVideoSize(Int.MAX_VALUE, maximumHeight).setPreferredAudioLanguages("en")
                            .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()
                        player.setMediaItem(mediaItem); player.prepare(); player.play()
                        status.text = "Native Media3 · protected playback"
                    }.onFailure { status.text = "Invalid session contract. Check your backend."; releaseSession() }
                }.onFailure { if (!closed.get()) status.text = "Authorization failed. Check login, Content ID, policy, and network." }
            }
        }
    }

    private fun releaseSession() { activeSession?.let(::workerRelease); activeSession = null }
    private fun workerRelease(session: JSONObject) {
        // A bounded release may finish after Activity destruction, including a
        // session response that arrived after the user left the screen.
        Thread({ runCatching {
            val release = session.getJSONObject("release")
            val headers = if (release.has("authorization")) authorization(release)
                else authorization(session.getJSONObject("drm"))
            post(release.getString("url"), "", headers)
        } }, "drmx-native-release").start()
    }
    private fun stopPlayback() { generation++; player.stop(); player.clearMediaItems(); releaseSession() }
    override fun onStop() { stopPlayback(); super.onStop() }
    override fun onDestroy() { closed.set(true); stopPlayback(); player.release(); worker.shutdown(); super.onDestroy() }

    companion object {
        fun https(url: String): String {
            val uri = URI(url); require(uri.scheme == "https" && uri.host != null && uri.userInfo == null)
            return url
        }
        fun authorization(drm: JSONObject): Map<String, String> {
            val auth = drm.optJSONObject("authorization")
            val name = auth?.getString("headerName") ?: "Authorization"
            val scheme = auth?.getString("scheme") ?: drm.optString("tokenType", "Bearer")
            val value = auth?.getString("value") ?: drm.getString("drmLicenseToken")
            require(name.matches(Regex("^[A-Za-z0-9-]{1,64}$")) && scheme.matches(Regex("^[A-Za-z][A-Za-z0-9._-]{0,31}$")))
            require(value.isNotBlank() && !value.contains('\r') && !value.contains('\n'))
            return mapOf(name to "$scheme $value")
        }
        fun post(url: String, body: String, headers: Map<String, String>): String {
            val connection = URI(https(url)).toURL().openConnection() as HttpURLConnection
            try {
                connection.requestMethod = "POST"; connection.instanceFollowRedirects = false
                connection.connectTimeout = 5000; connection.readTimeout = 15000; connection.doOutput = true
                connection.setRequestProperty("Content-Type", "application/json")
                headers.forEach { (name, value) -> connection.setRequestProperty(name, value) }
                connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
                require(connection.responseCode in 200..299) { "Customer request rejected" }
                return connection.inputStream.use { stream ->
                    val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192)
                    while (true) {
                        val count = stream.read(buffer); if (count < 0) break
                        require(output.size() + count <= 1_048_576); output.write(buffer, 0, count)
                    }
                    output.toString("UTF-8")
                }
            } finally { connection.disconnect() }
        }
    }
}

```



原生离线许可证可使用 `OfflineLicenseHelper.downloadLicense`，将其不透明的 Key-set ID 私有保存，再用 `MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).setKeySetId(id)` 恢复。以下已编译验证的辅助工具涵盖获取、有效时长检查、续期、释放，以及下载和播放项的构建。它**不是完整的下载应用**；缓存、下载服务、进度、元数据、用户和设备绑定及可信操作授权，仍需由您的应用实现。

原生持久许可证辅助工具（Kotlin，无 DRM-X 依赖）

```
package com.haihaisoft.drmx.samples.nativeplayer

import android.content.Context
import android.util.Base64
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.StreamKey
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.dash.DashUtil
import androidx.media3.exoplayer.drm.DrmSessionEventListener
import androidx.media3.exoplayer.drm.OfflineLicenseHelper
import androidx.media3.exoplayer.offline.DownloadRequest
import org.json.JSONObject

/** Native Media3 alternative: no DRM-X AAR. Call blocking operations on a worker.
 * Application owns downloads/cache, user/device entitlement, progress and lifecycle.
 * Never persist the session, request headers, tokens, challenges or license bytes.
 */
@UnstableApi
class NativeOfflineLicense(context: Context) {
    private val storage = context.getSharedPreferences("native.offline.keysets", Context.MODE_PRIVATE)
    private val upstream = DefaultHttpDataSource.Factory().setConnectTimeoutMs(10000).setReadTimeoutMs(20000)

    // session is the validated response from YOUR offline-license-session endpoint.
    fun acquire(session: JSONObject): ByteArray {
        validate(session)
        val contentId = session.getString("contentId")
        require(!storage.contains(contentId)) { "Renew or release the stored license before acquiring again" }
        val source = upstream.createDataSource()
        val manifest = DashUtil.loadManifest(source, android.net.Uri.parse(MainActivity.https(session.getString("manifestUrl"))))
        val format = DashUtil.loadFormatWithDrmInitData(upstream.createDataSource(), manifest.getPeriod(0))
            ?: error("Missing Widevine initialization data")
        val helper = helper(session)
        try {
            val id = helper.downloadLicense(format)
            save(contentId, id)
            return id
        } finally { helper.release() }
    }

    fun renew(contentId: String, freshSession: JSONObject) {
        validate(freshSession); require(freshSession.getString("contentId") == contentId)
        val helper = helper(freshSession)
        try { save(contentId, helper.renewLicense(keySet(contentId))) } finally { helper.release() }
    }

    // Local CDM check only. No token or network license URI is used.
    fun remainingSeconds(contentId: String): Pair<Long, Long> {
        val config = MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).build()
        val helper = OfflineLicenseHelper.newWidevineInstance(config, upstream, DrmSessionEventListener.EventDispatcher())
        try {
            val remaining = helper.getLicenseDurationRemainingSec(keySet(contentId))
            return Pair(remaining.first, remaining.second)
        } finally { helper.release() }
    }

    fun release(contentId: String, freshSession: JSONObject) {
        validate(freshSession); require(freshSession.getString("contentId") == contentId)
        val helper = helper(freshSession)
        try {
            helper.releaseLicense(keySet(contentId))
            check(storage.edit().remove(contentId).commit())
        } finally { helper.release() }
    }

    fun downloadRequest(contentId: String, downloadId: String, manifestUrl: String, streamKeys: List<StreamKey>): DownloadRequest {
        require(streamKeys.isNotEmpty()) { "Select policy-compliant video, audio and subtitle tracks first" }
        return DownloadRequest.Builder(downloadId, android.net.Uri.parse(MainActivity.https(manifestUrl)))
            .setMimeType("application/dash+xml").setStreamKeys(streamKeys).setKeySetId(keySet(contentId)).build()
    }

    fun mediaItem(contentId: String, manifestUrl: String, streamKeys: List<StreamKey>): MediaItem = MediaItem.Builder()
        .setUri(MainActivity.https(manifestUrl)).setMimeType("application/dash+xml").setStreamKeys(streamKeys)
        .setDrmConfiguration(MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID).setKeySetId(keySet(contentId)).build()).build()

    private fun helper(session: JSONObject): OfflineLicenseHelper {
        val drm = session.getJSONObject("drm")
        // Use HTTP license headers, NOT Widevine optional key-request parameters.
        val config = MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
            .setLicenseUri(MainActivity.https(drm.getString("licenseServerUrl")))
            .setForceDefaultLicenseUri(true).setLicenseRequestHeaders(MainActivity.authorization(drm)).build()
        return OfflineLicenseHelper.newWidevineInstance(config, upstream, DrmSessionEventListener.EventDispatcher())
    }
    private fun validate(session: JSONObject) {
        require(session.getInt("contractVersion") == 1 && session.getString("playbackMode") == "offline")
        require(session.getString("drmSystem") == "widevine" && session.getString("contentType") == "vod")
        require(session.getJSONObject("policy").getBoolean("persistent"))
    }
    private fun keySet(contentId: String): ByteArray = Base64.decode(storage.getString(contentId, null)
        ?: error("No device-bound license for this content"), Base64.NO_WRAP)
    private fun save(contentId: String, id: ByteArray) {
        check(storage.edit().putString(contentId, Base64.encodeToString(id, Base64.NO_WRAP)).commit())
    }
}

```



调用此底层辅助工具前，请验证新的后端响应是否匹配请求的 Content ID、操作、已登录用户及设备，以及授权有效期。将稳定的 offlineLicenseId、可信的 Manifest 和轨道元数据与 Key-set ID 一并保存，不要保存 Token。会阻塞的辅助方法应在工作线程中运行。许可证 HTTP 请求头通过 DRM 配置中的 `setLicenseRequestHeaders` 设置；Widevine 的可选密钥请求参数使用另一组映射。



新集成应使用 [AndroidX Media3 DRM 支持](https://developer.android.com/media/media3/exoplayer/drm)。旧版 `com.google.android.exoplayer2` 项目请遵循[官方迁移指南](https://developer.android.com/media/media3/exoplayer/migration-guide)。不要混用旧版与 AndroidX 类型，也不要假设示例能在旧版本中原样编译。



## 9. 检查、续期、释放及切换语言[#](#lifecycle-and-offline-behavior)



这些是独立操作。先释放正在使用的离线 DRM 会话，再一次执行一项操作：DrmXPlayer 使用 `player.stopForOfflineLicenseOperation { /* one operation below */ }`，已有原生 Media3 播放器则先释放播放器。不要在播放时另行检查许可证。检查仅用于显示有效时长，是可选操作，不得因此阻止过期许可证续期。



```
offlineManager.checkLicense(contentId) { result ->
    result.onSuccess { license ->
        if (license.state == DrmXOfflineLicenseState.EXPIRED) {
            // Require renewal online before offering offline playback.
        }
    }
}
offlineManager.renewLicense(offlineEndpoint, contentId, capabilities, customerHeaders) {
    /* update duration and UI after success; show retry on failure */
}
// Only when the customer requests deletion/release:
offlineManager.releaseLicense(offlineEndpoint, contentId, capabilities, customerHeaders) {
    /* after success, remove the corresponding encrypted download */
}
```



`getStoredLicense` 读取元数据；`checkLicense` 查询本地 CDM 中实际有效时长。续期和释放需要联网，并从后端取得针对该操作的新授权。删除媒体不会自动释放许可证。断网时应将释放操作排队，待下次联网执行，不要声称服务器已确认释放。管理器所属对象销毁时，应关闭管理器。



```
// Use player.media3Player for the DRM-X wrapper; nativePlayer for plain Media3.
nativePlayer.trackSelectionParameters = nativePlayer.trackSelectionParameters
    .buildUpon()
    .setPreferredAudioLanguages("en") // Chinese: "zh-Hans", "zh"
    .setPreferredTextLanguages("en")  // choose a packaged/downloaded language
    .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) // true = subtitles Off
    .build()
```



根据实际可用轨道生成菜单，并禁用不可用选项。语言偏好无法生成输出包中不存在的英语轨道；离线用户只能选择已下载语言。屏幕旋转时应保留许可证和缓存状态。全屏只是视图或窗口变化，不应因此重新获取许可证。



## 10. 实现离线授权接口[#](#offline-backend-contract)



应用提交 `contentId`、`contentType: "vod"`、`drmSystem: "widevine"`、`playbackMode: "offline"`、`operation: "acquire" | "renew" | "release"`、`offlineLicenseId`（首次获取时为 null）及如实填写的 `platformCapabilities`。确认续期或释放操作的 ID 属于已认证的用户、设备和内容。在服务器端选择策略，签发新的 DRM License Token，并返回匹配的接口契约：



```
{
  "contractVersion": 1, "playbackMode": "offline",
  "offlineLicenseId": "YOUR-STABLE-USER-DEVICE-CONTENT-RECORD",
  "contentId": "YOUR-PUBLISHED-CONTENT-ID", "contentType": "vod",
  "drmSystem": "widevine",
  "manifestUrl": "https://media.customer.example/title/manifest.mpd",
  "authorizationExpiresAt": "YOUR-FRESH-UTC-EXPIRY",
  "drm": {
    "system": "widevine",
    "licenseServerUrl": "https://license6.drm-x.com/v1/licenses/widevine",
    "integrationMode": "direct-token",
    "drmLicenseToken": "SHORT-LIVED-OPERATION-AUTHORIZATION",
    "tokenType": "Bearer"
  },
  "policy": {
    "persistent": true, "renewAllowed": true, "maximumHeight": 1080,
    "allowedTrackTypes": ["AUDIO", "SD", "HD"],
    "licenseDurationSeconds": 604800, "rentalDurationSeconds": 259200,
    "playbackDurationSeconds": 172800, "renewalDurationSeconds": 604800
  }
}
```



以下值仅用于说明，不能作为可复用的许可证响应。请返回实际操作产生的新值。SDK 还支持带有限制范围的 `drm.authorization` 描述字段的 `integrationMode: "token-proxy"`。返回 `Cache-Control: no-store`，不要记录成功授权的响应体。具体请求处理可参考可下载 SDK 中的 `OFFLINE_INTEGRATION.md` 及 PHP 示例的离线接口。



## 11. 设备测试与故障排查[#](#device-qualification)



### Widevine Provisioning 与中国网络测试



Provisioning 在获取内容许可证前为设备取得证书。从 preview.9 起，手机示例将 CDM 签名请求通过需要身份认证的客户 HTTPS 中继发送至 Google，签名响应会原样返回。这可帮助无法直连 Google Provisioning 服务的手机，但手机仍必须能够访问客户中继。



Provisioning 2.0 使用工厂 Keybox；Provisioning 4.0 使用不同的设备凭据模型。这些版本与 L1/L3 安全级别是两个概念，播放器不会切换 Provisioning 版本。已完成 Provisioning 的 L3 手机可以播放，另一台手机则可能仍需完成设备 Provisioning。这里引用的公开官方资料不能证明中国仅支持 2.0。参见 [NXP 设备集成指南](https://www.nxp.com/docs/en/user-guide/UG10158.pdf)及 [Android 的 Provisioning 实现](https://android.googlesource.com/platform/packages/apps/RemoteProvisioner/+/refs/tags/android-13.0.0_r15/src/com/android/remoteprovisioner/WidevineProvisioner.java)。



Preview.10 错误报告会区分直连或中继、超时、DNS/TLS/HTTP 故障，并记录 HTTP 状态及耗时。`provisioningModel: BootCertificateChain` 表示 AOSP 4.0 模型；`unknown` 或 `other` 不能证明使用 2.0。PHP 日志会区分输入被拒绝和 Google 上游故障。报告不包含签名载荷、URL、凭据或设备唯一标识。



直接升级已安装的 APK，在受影响网络上重试在线播放及一次下载，然后保留 Copy log 和大致发生时间。已有下载和许可证会保留。中继无法修复设备缺少 Widevine 实现，或设备/Google 拒绝凭据的问题；必须在受影响手机上实际验收。



从 preview.8 起，原生 Widevine L2/L3 或未知安全级别的播放，会在解码前限制为 SD（最高 576p）。请通过后端转发原始 Android 能力信息；会话 API 会将软件 Widevine 的多密钥许可证限制为音频和 SD 密钥。PHP 示例保留这些字段，并限制离线请求画质。内容必须包含 SD 版本，策略也必须允许软件 DRM。仅硬件安全 DRM 和统一单密钥安全规则保持不变。设置面板为半透明，不使视频变暗；安全错误报告中包含数值型 `cryptoErrorCode`。



| 测试/症状 | 预期行为/ 下一次检查 | 
| --- | --- |

| SDK 下载返回 403 | 选择订阅有效的 Professional 或 Enterprise 组织。仅计划在试用后升级，并不会解锁 SDK 下载。 | 

| 播放会话返回 401 / 403 | 检查登录、观看权限、Environment、状态为 Published 的 Content ID 及策略。不得绕过身份认证。 | 

| 在线播放正常，离线播放失败 | 检查 CDM 中的有效时长、下载是否完成、缓存位置、Stream keys，以及许可证是否包含所有必需密钥。 | 

| 缺少英语或中文轨道 | 检查内容包的语言元数据、实际轨道及离线语言选择。 | 

| L1,HDCP 或解码器失败 | 检查实体硬件、Provisioning、编码格式及输出要求。能力字符串不等于设备认证。 | 

| 重启、旋转、升级 | 受支持的生命周期变化后，持久许可证和缓存应继续可用，且不重复获取许可证。备份不得将许可证迁移到另一台设备。 | 

| 飞行模式 | 同时关闭 Wi-Fi 和移动数据，重启应用，验证本地播放并切换所有承诺支持的语言。 | 

| 许可证过期、存储空间不足、切换账户 | 清楚显示不可用或需要续期的状态，恢复未完成的媒体下载，并执行用户及设备访问限制，不得泄露其他用户的离线内容目录。 | 


针对宣称支持的每款手机、平板、Android TV/Google TV、Fire TV 或嵌入式 Android 设备，验证 Widevine、安全解码、编码格式与分辨率、HDCP、外接显示器、时钟变化及长时间断网。Android 设备的 DRM 能力并不相同。好莱坞内容还需满足版权方的保护与设备要求，成功解码本身并不足够。



**出发前没有机会联网获取许可证？**这需要另一种部署方式：旅途中由经过验证的机载或车载许可证服务，通过局域网签发许可证。仅接入本 SDK 不会启用此能力。DRM-X 可协助定制部署，涵盖 DRM 提供方授权、Provisioning、密钥保护及完全断网验收。参见 [飞机、巴士、火车及船舶的机载或车载 DRM](https://multi-drm.drm-x.com/zh-Hans/solutions/on-board-drm).



## Universal Player 播放列表和共享控件[#](#universal-player-playlists-and-shared-controls)



Android preview.11 及更新评估版，以及当前 Android/Apple 开发源码，共用视频内上一项、播放/暂停、下一项、CC、设置和全屏控件，播放时两秒后隐藏。Android 播放列表菜单已移除“Play next”。选中的随机和循环模式保持高亮，重启后仍保留；两者均关闭时，按保存顺序播放，并在最后一个视频结束后停止。拖动手柄可调整顺序，不中断当前视频，保存的顺序也适用于离线列表。这些最新源码 APIs 尚未包含在 Console 的 preview.10 SDK 包中。



设置播放列表前，向 `DrmXPlayer` 和 `playerView.storageNamespace` 提供同一个客户及用户 `storageNamespace`。VOD 进度会定期保存，并在暂停或停止时保存；再次在线或离线播放该 Content ID 时恢复。直播不使用保存的 VOD 位置。已完成下载的内容在无需网络媒体源时，仍支持相同的随机、循环及续播行为。这些状态仅保存在当前设备，不会跨设备同步。



Web 和 Android 版采用一致的深色设置面板与小型蓝色控件。播放列表支持上一项、下一项、随机播放、单项或全部循环，以及可取消的自动播放下一项。每个内容项都会发起新的授权播放请求。章节和文字稿元数据来自您的内容目录。



播放器支持播放速度、睡眠定时器、字幕、获准画质、音轨语言、会话书签和 A–B 循环。Web 版另提供键盘快捷键、可选的按用户隔离的本地续播、文字稿搜索，以及浏览器支持的 PiP。Android 保留原生 Widevine 离线下载及续期。投屏和跨设备进度同步需要由应用另行集成。



### Professional 和 Enterprise 的标识设置



品牌标识不覆盖视频画面。在 Settings → About player 中可查看获准使用的标识、产品信息和播放器版本；Web 版也可通过鼠标右键查看。公开手机示例包含 WayMaker、Goodness of God、Fresh Oil 和 4K Test Video；每次选择内容都会单独获取授权。



在可信后端将 `playerBranding` 设为 `{"mode":"hidden"}` 或 `{"mode":"custom","logoUrl":"https://your-site.example/logo.png","label":"Your brand"}`。平台会检查有效的 Professional 或 Enterprise 订阅。其他套餐使用 `{"mode":"default"}`；未获授权的自定义或隐藏标识请求会返回 403。品牌标识设置与安全水印分别控制。



PHP 下载包包含可编辑的播放列表和品牌配置。Android 客户 ZIP 包含播放列表回调示例。播放列表授权应由后端处理；不要在任一播放器中嵌入 API 客户端凭据。

<!-- Canonical: https://docs.drm-x.com/zh-Hans/sdk/android -->
<!-- Language: zh-Hans -->
