Player SDK
Trên trang này
TL;DR — Cài
TTSDKFramework1.52.100.4-standardbằ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
- 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.
- Đọc giá trị thiết bị (device ID →
union_info, device public key →drm_kek). Không bao giờ gửinil,"(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 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_tokenchỉ được giữ trong bộ nhớ: không nằm trongUserDefaults, 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.
Liên quan
Cập nhật 02/10/2026 · Lịch sử thay đổi (2)