Bản lưu ngày / Snapshot of 2026-10-06 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaThese docs are in beta and change often. Something wrong, unclear or missing? Tell us.
You are reading an older version of these docs: v1 · Go to the latest (v2)
Player SDK

Player SDK on Android

Markdown
On this page

TL;DR — Add com.bytedanceapi:ttsdk-player_standard:1.52.100.5 with Gradle (API level 16+, Java 8, armeabi-v7a/arm64-v8a), put the licence file Wandura sends you in your assets, start the SDK, and read the two device values after the licence check passes. Your app sends them to your backend, gets a ticket back, and gives the ticket to the SDK. On an SDK error, ask for one fresh ticket and resume.

Prerequisites

Licence The .lic file and App ID from Wandura, bound to your final package name. See Player SDK.
Toolchain Gradle, Android API level 16 or later, Java 8, ABIs armeabi-v7a or arm64-v8a. Permissions INTERNET, WAKE_LOCK, ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE.
Devices A real Android phone for encrypted playback.
Your backend A ticket endpoint: Playback flow and paywall.

The exact SDK calls (start-up, licence check, device values, playback, subtitles) are in the Player SDK guide on partner.wandura.ai → Developers → Player SDK. Wandura has verified this flow end to end on iOS; on Android it follows the vendor guide, so share your first test results in your channel.

The flow in your app

  1. Start the SDK at launch with your App ID and licence file, and check the licence before anything else.
  2. Read the device values (device ID → union_info, device public key → drm_kek). Never send null, "(null)" or an empty string: throw instead.
  3. Ask your backend to play the episode. Your backend applies your paywall and asks Wandura; your app never holds an API key.
  4. Play: give vid and play_auth_token to the SDK; add the subtitles.
  5. Preload the next episodes with the prefetched tickets your backend returns.
  6. On any SDK error (an expired ticket included): request one fresh ticket and resume at the same position. Do not branch on SDK error codes, and do not loop.
  7. On resume: if the current ticket has less than a minute left, replace it before playing on.

Code

The file below is the app side of that flow, in Kotlin. Only PlayerSdk touches the SDK: fill it in with the calls from the portal guide. Your backend's response shape matches the Node and Python backend examples.

The SDK behind one 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)?
}

Your backend's contract

@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
}

Start, prefetch, expiry, errors

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

  • The licence check passes before device values are read.
  • Device values are never null, "(null)" or empty when sent.
  • The app holds no wnd_live_ key (search the built APK/AAB).
  • play_auth_token is kept in memory only: not in SharedPreferences, logs or crash reports.
  • An SDK error leads to one fresh ticket and a resume, never a loop.
  • Resuming after more than 29 minutes replaces the ticket first.
  • An episode without subtitles plays without an error.

Updated 5 Oct 2026 · Change history (2)

↑↓ selectEnter openEsc close

Send feedback

Your message goes straight to the Wandura team. It includes the address of this page.

0 / 4000