Bản lưu ngày / Snapshot of 2026-10-02 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaDocs đang ở bản beta và còn thay đổi. Thấy chỗ sai, khó hiểu hoặc còn thiếu? Hãy cho chúng tôi biết.
Player SDK

Player SDK trên Android

Markdown
Trên trang này

TL;DR — Thêm com.bytedanceapi:ttsdk-player_standard:1.52.100.5 bằ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

  1. 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.
  2. Đọc giá trị thiết bị (device ID → union_info, device public key → drm_kek). Không bao giờ gửi null, "(null)" hay chuỗi rỗng: hãy ném lỗi (throw) thay vào đó.
  3. 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.
  4. Phát: đưa vid và play_auth_token cho SDK; thêm phụ đề.
  5. Tải trước các tập kế tiếp bằng các vé prefetch mà backend trả về.
  6. 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.
  7. 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_token chỉ được giữ trong bộ nhớ: không nằm trong SharedPreferences, 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.

Cập nhật 02/10/2026 · Lịch sử thay đổi (2)

↑↓ chọnEnter mởEsc đóng

Gửi góp ý

Góp ý được gửi thẳng tới đội Wandura, kèm địa chỉ trang này.

0 / 4000