Android SDK 与原生 Media3
下载 Android 播放器,体验可拖动排序的播放列表、可保存的随机播放和循环设置,以及离线续播。通过 DRM-X 或原生 Media3 集成受保护的播放。
有关 UI 自定义、参数默认值和生命周期示例,请参阅 PLAYER-UI-CONFIGURATION.md。播放器自带的弹出面板均不透明;字幕和水印的不透明度可单独设置。
按照 SDK ZIP 中的 DEMO-QUICKSTART.md 试用 PHP 演示。使用您自己的内容时,请先完成网站/后端集成,再按照 MOBILE-AND-TV-APPS.md 将应用连接到同一套登录系统和观看权限规则。
试试看 Android 播放器#
1.0.0-beta.11 · 12.15 MB · Android 6.0+ (API 23+) · DRM-X 6.0 Player
- 在您的Android 手机上打开此页面并下载APK 。
- 打开下载的文件。如果 Android 提示,请允许当前浏览器或文件管理器安装应用,然后点击 Install。
- 打开 DRM-X 6.0 Player,保持联网,点击视频的 Play 按钮。只有点击 Play 时,应用才会请求在线播放许可证。
- 打开 Settings 选择画质、音轨、字幕或播放速度。旋转手机后可继续全屏播放。
- 打开视频旁的三横线菜单,选择 Download。开启 Downloaded videos 进入 Offline Mode,关闭则进入 Online Mode。在 Offline Mode 中,从该视频的菜单选择 Delete 并确认。许可证续期需要联网并获得当前授权;发生离线错误时,可以选择继续离线或切换到在线模式。
已排队的媒体下载会在后台继续。如果 Android 在准备许可证或轨道时终止应用,请联网后重新打开,完成剩余内容的准备。已有下载会保留。离线播放只使用缓存媒体;未下载完成或许可证已过期的内容,需要先完成下载或续期。
公开示例 APK 已签名,供评估使用。下方 AAR 和完整 SDK 仍通过符合资格的 Console 账户提供。生产应用应使用自己独立的签名密钥及后端登录。
APK校验和USB安装
SHA-256:
b96ba527ddf9abae5bff2f8b85a39a12b59c934aaacde1961c8096aa32524c54使用Android平台工具及授权的USB设备:
adb install -r drmx-android-player-1.0.0-beta.11.apk直接安装此 APK 升级现有官方示例,无需卸载。相同签名密钥和应用 ID 可保留下载、离线许可证、播放进度及偏好。如果自行构建使用不同签名密钥,请使用另一个应用 ID,让两个应用共存并保留官方示例的离线许可证。
preview.40 中的下载控制与语言名称#
开始下载前请连接网络。在下载对话框中选择允许的视频画质和音轨;可用字幕会一并下载。音频和字幕菜单使用语言本身的名称,例如 English 和中文。可选的最高画质取决于客户配置及设备 DRM 支持。
在 Offline Mode 中,打开视频旁的三横线菜单并选择 Delete。确认后,只删除该视频在本设备上的加密媒体和许可证引用;选择 Cancel 则保留下载。重新联网后可再次下载。
新建下载需要联网。断网时排队中的传输可暂停,重新联网后继续。下载完成后,只要设备绑定的许可证仍有效,就可以离线播放。播放列表海报和进度预览缩略图按下文说明缓存。
历史版本 preview.39:离线图片与下载#
播放列表海报和进度预览缩略图会连同图片元数据保存在设备上。关闭应用后,即使未联网,重新打开时仍可使用。升级后请在联网状态下打开一次离线演示媒体库,等待图片加载;已有加密视频无需重新下载。
等待各个视频下载完成,再同时关闭 Wi-Fi 和移动数据,选择 Play offline。测试已下载播放列表、所选音轨和随附字幕。许可证需要续期时,请先联网再使用 Renew。覆盖安装更新可保留原有下载和设置。
发布者图片必须先在联网时加载,才能离线显示。图片使用最多 96 MiB 的应用私有缓存,与加密媒体和许可证分开存储;缓存满时可能移除较旧图片。图片缺失不会绕过 DRM,也不会阻止有效的已下载视频播放。
历史版本 preview.13:直播 DRM#
2026 年 9 月 17 日发布。手机应用和客户 SDK 包含 4K Live DRM Test。直播播放使用最新 manifest,不能下载。测试条目仅在实际推流期间可用。参照 AWS 和 OBS 设置指南创建自己的活动。
Android 播放器 preview.12 更新内容#
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 / 软件包 20260910.8。
Android 播放器 preview.11 更新内容#
2026 年 9 月 14 日发布。此评估应用包含最新播放器控件和播放列表改进,与 Android 和 Apple 播放器保持一致的交互方式。
- 视频内控件:包含居中的 Play/Pause、多视频列表的 Previous 和 Next、CC、Settings 及全屏按钮。播放时两秒后隐藏,点击画面即可重新显示。
- 字幕与播放设置:CC 可显示或隐藏可用字幕;Settings 可选择字幕语言、音轨、播放速度和获准画质。离线模式只提供已下载的轨道。
- 拖动排序:拖动播放列表项的手柄可调整位置。顺序在重启后保留,也应用于已下载列表,调整时不会中断当前视频。点击手柄可使用便于无障碍操作的 Move up / Move down。
- 保存随机和循环设置:选中模式保持高亮,在线和离线播放均在重启后保留。随机和循环都关闭时,按保存顺序播放,并在最后一项结束后停止。播放列表菜单已移除“Play next”。
- 从上次位置继续:再次在线或离线播放时,会恢复该视频保存的 VOD 进度。进度按当前用户保存在此设备上,不跨设备同步。
管理播放列表、保存顺序并继续播放。离线观看需要已完成的下载和可用且未过期的离线许可证。
preview.11 更新了公开评估 APK。当前 Console SDK 为 1.0.0-beta.14,软件包版本为 20261028.1;下方集成说明使用此版本。
1. 选择集成路径#
使用 DrmXPlayer、手机示例及离线管理器,并接入自己的登录和业务规则。
路径 B · 保留现有界面Media3 + DRM-X 辅助工具保留已有播放器及下载实现,由 DrmXOfflineManager 管理持久许可证。
路径 C · 仅原生集成不使用 AAR 的 Media3 集成自行实现相同的后端接口契约和原生许可证生命周期,无需依赖 DRM-X 播放器。
在 Developer Tools → Device SDKs 下载。使用 SDK 需要有效的 Professional 或 Enterprise 权限,包括已获批准的 Professional 评估账户。Standard 仍可通过原生 APIs 构建自己的播放器。创建会话和续订许可证时会检查 SDK 权限;离线许可证的有效期不会超过账户服务期。
当前预览版本:1.0.0-beta.14 · 客户 SDK 包:20261028.1 · Media3 1.11.0。ZIP 包含本地 Maven 仓库、Gradle wrapper、带注释的 README、手机/电视/原生集成示例、位于 apks/ 的可安装手机示例 APK,以及离线集成说明。包内不含客户凭据或受保护的视频。
2. 准备内容和后端#
- 在您的 DRM-X Environment 中打包加密并发布兼容 Widevine 的 DASH 视频,然后准确复制其 Published Content ID。
- 在服务器上配置 Environment ID、Site Key 和 Access Key。可从可下载的 PHP 集成示例开始。
- 实现用户登录及观看权限检查。应用使用短期用户 Token 调用后端,再由后端确定内容、策略和可信 URL。
- 先在支持 Widevine 的实体设备上验证在线播放。确认在线播放接口契约正常后,再接入离线许可证获取。
加密视频和 DRM 许可证通过不同路径传输
CDN 负责传输加密视频。您的后端检查观看权限,DRM 许可证服务则授权兼容设备解密播放。
- 存储 / CDN
向播放器传输加密媒体。
- 您的后端
确认用户有权观看后,向 DRM-X 请求播放授权。
- DRM-X
验证播放授权并处理 DRM 许可证请求。
- 播放器 + 设备
使用设备的 DRM 系统获取许可证,并播放加密媒体。
| 更改此值 | 配置位置 | 含义 |
|---|---|---|
| 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#
在 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-beta.14")
}
使用独立 AAR 的安装方式
将 AAR 放入应用模块的 libs/。独立 AAR 不会自动解析传递依赖,因此需要自行加入三个匹配版本的 Media3 依赖。不要同时安装两种 SDK 形式。
implementation(files("libs/drmx-universal-player-1.0.0-beta.14.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# 构建测试APK
此示例使用完整 SDK ZIP 中可运行的手机项目,并明确引用独立 AAR。已有 Android 应用也可采用同样方式。
- 下载并解压完整 Android SDK ZIP,在 Android Studio 中打开其根目录。
- 将
libs/drmx-universal-player-1.0.0-beta.14.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-beta.14.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-beta.14"),避免重复引入同一个 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 后授权、旋转适配、下载归属、离线播放及续期。接入自己的应用时,可参考下方控件集成示例。
4. 服务器请求与默认策略#
Android 应用向您的后端请求播放会话。后端认证用户、检查观看权限,将 Content ID 映射到已发布媒体后调用 DRM-X。完整的在线播放接口契约见播放会话 API。
{
"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 环境变量,不要通过播放器的查询参数修改策略。参见许可证策略。
playback_policy.persistent设置为 true,并选择许可证、租赁、播放和续订期限。参考分别使用 7 天/3 天/48 小时/7 天;这些是示例业务规则,而不是工作室批准或平台范围的默认规则。返回匹配的元数据。 Widevine 许可证仍然具有权威性。5. 路径A:通过 DrmXPlayer 播放#
在 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 或响应体。完整生命周期、全屏控件及下载队列可参考随附手机示例。
播放器控件与旋转时连续播放#
点击视频中的 Play 才开始授权。打开页面或 Settings 不会获取在线播放许可证。暂停与恢复、更改设置或旋转手机均保留当前会话。

| 控件 | 顾客能做的 |
|---|---|
| 画质 | 选择 Auto,或 Manifest 中设备支持且许可证允许的分辨率。不可用选项会说明许可证、设备或 DRM 限制;Auto 同样遵守这些限制。 |
| 音频轨道 | 选择加密输出包提供的任何支持的音频语言 。 |
| Subtitles/CC | 选择字幕语言或 Off。存在受支持的字幕时会显示 CC 按钮,可直接开启或关闭字幕。 |
| 字幕外观 | 可调整字体、文字颜色与大小、背景和字幕窗口的颜色及不透明度。偏好设置保存在设备上。 |
| 播放速度 / 睡眠定时器 | 支持 0.25–2× 播放速度,也可设置在指定时间后自动暂停。 |
| 进度跳转 / 全屏 | 拖动进度条、前后跳转 10 秒,或双击画面左右两侧。旋转到横屏时无需重新开始播放。 |
每个视频的菜单都提供 Move up / Move down,在线模式下另有 Download,离线模式下另有 Delete。下载完成后,Download 会被禁用。Downloaded videos 开关用于选择离线播放,此时只能使用已缓存的音轨和字幕。界面不再提供独立的下载、删除或整份播放列表下载按钮。
信息区旁的 Copy log 可将最近错误复制为文本,便于提交支持请求,无需截图。报告包含报告 ID、操作、SDK/Android/设备版本、数值型 CDM/Media3 错误、选定堆栈帧、连接/下载/播放状态及重复次数。不包含凭据、媒体 URL、密钥、Key-set ID、许可证数据或完整 CDM 转储。
示例最多保留 32 份本地报告(64 KB),保存五小时。联网且已登录时,向配置的客户后端上传待发送错误,频率不超过每 30 秒一次。公开演示服务器保存五小时的警告和错误,合并重复事件,将文件限制为 512 KB,并每分钟清理。常规进度及成功播放不记入日志。自定义集成必须明确提供自己的同源 drmx.diagnosticsEndpoint;诊断失败不会中断播放。
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. 获得许可证和下载媒体#
准备离线播放
完整的下载和有效且绑定到设备的许可证,二者缺一不可。
- 下载加密媒体
联网时下载视频,以及所需的音轨和字幕。
- 获取持久许可证
联网时获取绑定到设备的 Widevine 许可证,并私有保存其不透明的 Key-set ID。
- 检查是否就绪
确认下载已完成,且许可证仍然有效。
- 离线播放
读取缓存媒体并恢复本地许可证。续期和释放需要联网。
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 前台服务和通知要求;演示队列在应用进程内运行。
7. 路径B:用现有的 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#
您不需要使用 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 支持。旧版 com.google.android.exoplayer2 项目请遵循官方迁移指南。不要混用旧版与 AndroidX 类型,也不要假设示例能在旧版本中原样编译。
9. 检查、续期、释放及切换语言#
这些是独立操作。先释放正在使用的离线 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. 实现离线授权接口#
应用提交 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. 设备测试与故障排查#
| 测试/症状 | 预期行为/ 下一次检查 |
|---|---|
| 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 能力并不相同。好莱坞内容还需满足版权方的保护与设备要求,成功解码本身并不足够。
Universal Player 播放列表和共享控件#
Android 和 Apple 播放器提供 Previous/Play-Pause/Next、CC、Settings 和全屏控件。Move up / Move down 不会切换当前视频,并会保存在线和离线播放共用的列表顺序。随机播放和重复播放的选择在重新打开应用后仍会保留;两者均关闭时,播放将在最后一项结束后停止。
设置播放列表前,向 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 客户端凭据。
DRM-X 6.0