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.
Player SDK

Player SDK on iOS

Markdown
On this page

TL;DR — Install TTSDKFramework 1.52.100.4-standard with CocoaPods (iOS 11+, Xcode 14+, Bitcode off), add the licence file Wandura sends you, 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 bundle ID. See Player SDK.
Toolchain Xcode 14 or later, iOS 11.0 or later, Bitcode disabled, CocoaPods (always open the .xcworkspace).
Devices A real iPhone for encrypted playback.
Your backend A ticket endpoint: Playback flow and paywall.

The exact SDK calls (start-up, licence notification, device values, playback, subtitles) are in the Player SDK guide on partner.wandura.ai → Developers → Player SDK.

The flow in your app

  1. Start the SDK at launch with your App ID and licence file, and wait for the licence result before anything else.
  2. Read the device values (device ID → union_info, device public key → drm_kek). Never send nil, "(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. Back from the background: if the current ticket has less than a minute left, replace it before resuming.

Code

The file below is the app side of that flow, in Swift. 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 protocol

struct DeviceValues {
    let unionInfo: String // device ID generated by the Player SDK
    let drmKek: String // device public key generated by the Player SDK
}

protocol PlayerSDK: AnyObject {
    /// Read only after the SDK has started and its licence check succeeded. Throws instead of returning nil:
    /// never send "(null)", "nil" or an empty string to your backend.
    func deviceValues() throws -> DeviceValues
    func play(vid: String, playAuthToken: String, startAt seconds: Double)
    func preload(vid: String, playAuthToken: String)
    func addSubtitles(_ subtitles: [Subtitle])
    var currentTime: Double { get }
    /// A playback error, including an expired ticket. Do not branch on SDK error codes.
    var onError: ((Error) -> Void)? { get set }
}

Calling your backend

final class Backend {
    private let baseURL: URL
    private let session: URLSession
    private let sessionToken: () -> String // YOUR viewer session, never a Wandura key

    init(baseURL: URL, sessionToken: @escaping () -> String) {
        self.baseURL = baseURL
        self.sessionToken = sessionToken
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 15 // your backend may retry Wandura for a few seconds
        session = URLSession(configuration: config)
    }

    func play(filmId: String, episodeNo: Int, device: DeviceValues) async throws -> PlayResponse {
        var req = URLRequest(url: baseURL.appendingPathComponent("v1/play"))
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue("Bearer \(sessionToken())", forHTTPHeaderField: "Authorization")
        req.httpBody = try JSONSerialization.data(withJSONObject: [
            "film_id": filmId, "episode_no": episodeNo,
            "union_info": device.unionInfo, "drm_kek": device.drmKek,
        ])
        let (data, _) = try await session.data(for: req)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .custom { d in
            let s = try d.singleValueContainer().decode(String.self)
            let f = ISO8601DateFormatter()
            f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
            if let date = f.date(from: s) ?? ISO8601DateFormatter().date(from: s) { return date }
            throw DecodingError.dataCorrupted(.init(codingPath: d.codingPath, debugDescription: "bad date \(s)"))
        }
        return try decoder.decode(PlayResponse.self, from: data)
    }
}

Start, prefetch, expiry, errors

enum ScreenState {
    case loading
    case playing
    case paywall
    case message(String, canRetry: Bool)
}

@MainActor
final class PlaybackController {
    private let sdk: PlayerSDK
    private let backend: Backend
    private var prefetched: [Int: Ticket] = [:] // episode number → ticket for THIS device only
    private var current: (filmId: String, episodeNo: Int, ticket: Ticket)?
    private var refreshedAfterError = false
    var onState: ((ScreenState) -> Void)?

    init(sdk: PlayerSDK, backend: Backend) {
        self.sdk = sdk
        self.backend = backend
        sdk.onError = { [weak self] _ in Task { @MainActor in await self?.recoverFromPlayerError() } }
    }

    /// The viewer tapped an episode.
    func start(filmId: String, episodeNo: Int) async {
        say(.loading)
        refreshedAfterError = false
        let device: DeviceValues
        do { device = try sdk.deviceValues() } catch {
            return say(.message("Couldn't load this episode", canRetry: false)) // SDK setup bug: alert, don't loop
        }
        // A prefetched ticket starts the episode at once. The play request is still sent: it records the view.
        if let t = prefetched.removeValue(forKey: episodeNo), t.isFresh {
            begin(filmId: filmId, episodeNo: episodeNo, ticket: t, at: 0)
        }
        do {
            let r = try await backend.play(filmId: filmId, episodeNo: episodeNo, device: device)
            if r.ok, let ticket = r.current {
                if current?.episodeNo != episodeNo { begin(filmId: filmId, episodeNo: episodeNo, ticket: ticket, at: 0) }
                for n in r.next ?? [] { // a prefetch missing from `next` is not an error
                    prefetched[n.episodeNo] = n.ticket
                    sdk.preload(vid: n.ticket.vid, playAuthToken: n.ticket.playAuthToken)
                }
            } else if current?.episodeNo != episodeNo {
                show(r.outcome ?? .broken)
            }
        } catch {
            if current?.episodeNo != episodeNo { say(.message("Taking longer than usual", canRetry: true)) }
        }
    }

    /// Expired ticket or any SDK error: ONE fresh ticket, resume at the same position. Never a loop.
    private func recoverFromPlayerError() async {
        guard let c = current, !refreshedAfterError, let device = try? sdk.deviceValues() else {
            return say(.message("Couldn't load this episode", canRetry: true))
        }
        refreshedAfterError = true
        let position = sdk.currentTime
        guard let r = try? await backend.play(filmId: c.filmId, episodeNo: c.episodeNo, device: device),
              r.ok, let ticket = r.current
        else { return say(.message("Couldn't load this episode", canRetry: true)) }
        begin(filmId: c.filmId, episodeNo: c.episodeNo, ticket: ticket, at: position)
    }

    /// Back from the background: a ticket with less than a minute left is replaced before resuming.
    func willEnterForeground() async {
        guard let c = current, !c.ticket.isFresh else { return }
        refreshedAfterError = false
        await recoverFromPlayerError()
    }

    private func begin(filmId: String, episodeNo: Int, ticket: Ticket, at seconds: Double) {
        current = (filmId, episodeNo, ticket)
        sdk.play(vid: ticket.vid, playAuthToken: ticket.playAuthToken, startAt: seconds)
        sdk.addSubtitles(ticket.subtitles) // empty list = no subtitles yet, not an error
        say(.playing)
    }

    private func say(_ state: ScreenState) { onState?(state) }

    private func show(_ outcome: Outcome) {
        switch outcome {
        case .locked: say(.paywall)
        case .unavailable: say(.message("This title is no longer available", canRetry: false))
        case .notReady: say(.message("Not available yet", canRetry: false))
        case .busy: say(.message("Busy, try again in a moment", canRetry: true))
        case .broken: say(.message("Couldn't load this episode", canRetry: true))
        }
    }
}

Checklist

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

Updated 2 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