Bản lưu ngày / Snapshot of 2026-10-19 · 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.
Bạn đang đọc một bản tài liệu cũ: v1 · Sang bản mới nhất (v2)
Player SDK

Player SDK trên iOS

Markdown
Trên trang này

TL;DR — Cài TTSDKFramework 1.52.100.4-standard bằng CocoaPods (iOS 11+, Xcode 14+, tắt Bitcode), thêm tệp giấy phép Wandura gửi bạn, 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 bundle ID chính thức của bạn. Xem Player SDK.
Công cụ Xcode 14 trở lên, iOS 11.0 trở lên, tắt Bitcode, CocoaPods (luôn mở .xcworkspace).
Thiết bị Một iPhone 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, thông báo 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.

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à chờ kết quả 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 nil, "(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 quay lại từ nền (background): 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 Swift. 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 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 }
}

Gọi backend của bạn

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

Bắt đầu, prefetch, hết hạn vé, lỗi

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

  • Kết quả kiểm giấy phép được kiểm tra trước khi đọc giá trị thiết bị.
  • Giá trị thiết bị khi gửi không bao giờ là nil, "(null)" hay rỗng.
  • App không chứa key wnd_live_ nào (tìm trong .ipa đã build).
  • play_auth_token chỉ được giữ trong bộ nhớ: không nằm trong UserDefaults, 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 ở nền 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 05/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