Trên trang này
TL;DR — Thêm
com.bytedanceapi:ttsdk-player_standard:1.52.100.5bằng Gradle (API level 16+, Java 8,armeabi-v7a/arm64-v8a), đặt tệp giấy phép Wandura gửi bạn vào assets, khởi động SDK, và đọc hai giá trị thiết bị sau khi qua bước kiểm giấy phép. App gửi chúng về backend của bạn, nhận lại vé, và đưa vé cho SDK. Khi SDK báo lỗi, xin một vé mới rồi phát tiếp.
Điều kiện cần
| Giấy phép | Tệp .lic và App ID từ Wandura, gắn với package name chính thức của bạn. Xem Player SDK. |
| Công cụ | Gradle, Android API level 16 trở lên, Java 8, ABI armeabi-v7a hoặc arm64-v8a. Quyền INTERNET, WAKE_LOCK, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE. |
| Thiết bị | Một điện thoại Android thật để phát nội dung mã hoá. |
| Backend của bạn | Một endpoint xin vé: Luồng playback và paywall. |
Các lệnh gọi SDK chính xác (khởi động, kiểm giấy phép, giá trị thiết bị, playback, phụ đề) nằm trong hướng dẫn Player SDK ở partner.wandura.ai → Developers → Player SDK. Wandura đã kiểm chứng trọn vẹn luồng này trên iOS; trên Android, luồng làm theo hướng dẫn của nhà cung cấp, nên hãy chia sẻ kết quả test đầu tiên trong kênh của bạn.
Luồng trong app của bạn
- Khởi động SDK lúc mở app bằng App ID và tệp giấy phép, và kiểm giấy phép trước khi làm bất cứ việc gì khác.
- Đọc giá trị thiết bị (device ID →
union_info, device public key →drm_kek). Không bao giờ gửinull,"(null)"hay chuỗi rỗng: hãy ném lỗi (throw) thay vào đó. - Gọi backend của bạn để phát tập. Backend áp paywall của bạn rồi xin Wandura; app của bạn không bao giờ giữ API key.
- Phát: đưa
vidvàplay_auth_tokencho SDK; thêm phụ đề. - Tải trước các tập kế tiếp bằng các vé prefetch mà backend trả về.
- Khi có bất kỳ lỗi SDK nào (kể cả vé hết hạn): xin một vé mới và phát tiếp tại đúng vị trí. Đừng rẽ nhánh theo mã lỗi SDK, và đừng lặp.
- Khi app quay lại (resume): nếu vé hiện tại còn dưới một phút, hãy thay vé trước khi phát tiếp.
Code
Tệp bên dưới là phía app của luồng đó, viết bằng Kotlin. Chỉ PlayerSdk chạm vào SDK: hãy điền vào đó các lệnh gọi lấy từ hướng dẫn trên portal. Dạng response của backend khớp với các ví dụ backend Node và Python.
SDK đứng sau một interface
data class DeviceValues(val unionInfo: String, val drmKek: String)
interface PlayerSdk {
/** Read only after the SDK has started and its licence check succeeded. Throws instead of returning null:
* never send "(null)", "null" or an empty string to your backend. */
fun deviceValues(): DeviceValues
fun play(vid: String, playAuthToken: String, startAtMs: Long)
fun preload(vid: String, playAuthToken: String)
fun addSubtitles(subtitles: List<Subtitle>)
val currentPositionMs: Long
/** A playback error, including an expired ticket. Do not branch on SDK error codes. */
var onError: ((Throwable) -> Unit)?
}Hợp đồng với backend của bạn
@Serializable
data class Subtitle(val lang: String, val url: String, val format: String)
@Serializable
data class Ticket(
val vid: String,
@SerialName("play_auth_token") val playAuthToken: String, // memory only: never log, never persist
@SerialName("expires_at") val expiresAt: String,
val subtitles: List<Subtitle> = emptyList(),
) {
/** Wandura's clock decides; allow a minute for a device clock that runs slow. */
fun isFresh(now: Instant = Instant.now()): Boolean = Instant.parse(expiresAt).isAfter(now.plusSeconds(60))
}
@Serializable
data class NextTicket(@SerialName("episode_no") val episodeNo: Int, val ticket: Ticket)
/** The shape the example backends (Node, Python) return from YOUR `POST /v1/play`. */
@Serializable
data class PlayResponse(
val ok: Boolean,
val current: Ticket? = null,
val next: List<NextTicket> = emptyList(),
val outcome: String? = null, // locked | unavailable | not_ready | busy | broken — unknown = broken
)
/** Your backend. Implement with your HTTP client (OkHttp, Retrofit, Ktor) and a 15-second timeout: your backend may
* retry Wandura for a few seconds. Send YOUR viewer session, never a Wandura key. */
interface Backend {
suspend fun play(filmId: String, episodeNo: Int, device: DeviceValues): PlayResponse
}Bắt đầu, prefetch, hết hạn vé, lỗi
sealed interface ScreenState {
data object Loading : ScreenState
data object Playing : ScreenState
data object Paywall : ScreenState
data class Message(val text: String, val canRetry: Boolean) : ScreenState
}
class PlaybackController(
private val sdk: PlayerSdk,
private val backend: Backend,
private val scope: CoroutineScope,
private val onState: (ScreenState) -> Unit,
) {
private val prefetched = mutableMapOf<Int, Ticket>() // episode number → ticket for THIS device only
private var current: Triple<String, Int, Ticket>? = null
private var refreshedAfterError = false
init {
sdk.onError = { scope.launch { recoverFromPlayerError() } }
}
/** The viewer tapped an episode. */
suspend fun start(filmId: String, episodeNo: Int) {
onState(ScreenState.Loading)
refreshedAfterError = false
val device = runCatching { sdk.deviceValues() }.getOrElse {
return onState(ScreenState.Message("Couldn't load this episode", canRetry = false)) // SDK setup bug
}
// A prefetched ticket starts the episode at once. The play request is still sent: it records the view.
prefetched.remove(episodeNo)?.takeIf { it.isFresh() }?.let { begin(filmId, episodeNo, it, 0) }
val r = runCatching { backend.play(filmId, episodeNo, device) }.getOrNull()
when {
r?.ok == true && r.current != null -> {
if (current?.second != episodeNo) begin(filmId, episodeNo, r.current, 0)
r.next.forEach { n -> // a prefetch missing from `next` is not an error
prefetched[n.episodeNo] = n.ticket
sdk.preload(n.ticket.vid, n.ticket.playAuthToken)
}
}
current?.second == episodeNo -> Unit // already playing from the prefetched ticket
r == null -> onState(ScreenState.Message("Taking longer than usual", canRetry = true))
else -> show(r.outcome)
}
}
/** Expired ticket or any SDK error: ONE fresh ticket, resume at the same position. Never a loop. */
private suspend fun recoverFromPlayerError() {
val c = current
val device = runCatching { sdk.deviceValues() }.getOrNull()
if (c == null || refreshedAfterError || device == null) {
return onState(ScreenState.Message("Couldn't load this episode", canRetry = true))
}
refreshedAfterError = true
val position = sdk.currentPositionMs
val r = runCatching { backend.play(c.first, c.second, device) }.getOrNull()
val ticket = r?.current
if (r?.ok != true || ticket == null) return onState(ScreenState.Message("Couldn't load this episode", canRetry = true))
begin(c.first, c.second, ticket, position)
}
/** Back from the background: a ticket with less than a minute left is replaced before resuming. */
suspend fun onResume() {
val c = current ?: return
if (c.third.isFresh()) return
refreshedAfterError = false
recoverFromPlayerError()
}
private fun begin(filmId: String, episodeNo: Int, ticket: Ticket, atMs: Long) {
current = Triple(filmId, episodeNo, ticket)
sdk.play(ticket.vid, ticket.playAuthToken, atMs)
sdk.addSubtitles(ticket.subtitles) // empty list = no subtitles yet, not an error
onState(ScreenState.Playing)
}
private fun show(outcome: String?) = onState(
when (outcome) {
"locked" -> ScreenState.Paywall
"unavailable" -> ScreenState.Message("This title is no longer available", canRetry = false)
"not_ready" -> ScreenState.Message("Not available yet", canRetry = false)
"busy" -> ScreenState.Message("Busy, try again in a moment", canRetry = true)
else -> ScreenState.Message("Couldn't load this episode", canRetry = true)
},
)
}Checklist
- Bước kiểm giấy phép phải qua trước khi đọc giá trị thiết bị.
- Giá trị thiết bị khi gửi không bao giờ là
null,"(null)"hay rỗng. - App không chứa key
wnd_live_nào (tìm trong APK/AAB đã build). -
play_auth_tokenchỉ được giữ trong bộ nhớ: không nằm trongSharedPreferences, log hay crash report. - Lỗi SDK dẫn tới một vé mới và phát tiếp, không bao giờ thành vòng lặp.
- Phát tiếp sau hơn 29 phút thì thay vé trước.
- Tập không có phụ đề vẫn phát mà không báo lỗi.
Liên quan
Cập nhật 02/10/2026 · Lịch sử thay đổi (2)