# Player SDK on iOS

> **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](https://docs.wandura.ai/developers/player-sdk#licence). |
| 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](https://docs.wandura.ai/developers/guides/playback-flow). |

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](https://docs.wandura.ai/developers/guides/playback-flow#code).

### The SDK behind one protocol
```swift
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
```swift
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
```swift
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.

## Related
- [Player SDK overview and licence](https://docs.wandura.ai/developers/player-sdk)
- [Smooth playback under load](https://docs.wandura.ai/developers/guides/smooth-playback#in-the-app)
- [Subtitles](https://docs.wandura.ai/developers/guides/subtitles)
