Havik Player iOS SDK API Reference
HavikPlayer (Swift Package) · iOS player SDK for Oddin live streams (LL-HLS + FairPlay).
The native sibling of @oddin-gg/havik-player: same streams, same play sequence, same event and error vocabulary. iOS 15+.
Contents
- Overview
- Installation
- Concepts
- Mode A: Managed player
- Mode B: Bring your own AVPlayer
- Reference
- Versioning & support
Overview
The SDK plays Oddin live streams over Low-Latency HLS with FairPlay DRM, using AVPlayer + AVContentKeySession. One engine, no third-party playback stack. Two integration modes:
- Mode A (managed):
HavikPlayerowns anAVPlayerand wires live pickup, DRM, retries, stats, and end-of-stream detection automatically.HavikPlayerViewrenders it in SwiftUI. - Mode B (bring your own player):
HavikClient.resolveStream+ContentKeyCoordinator, attached to anAVPlayeryou own.
The iframe-embed mode of the web SDK has no iOS equivalent.
Installation
The package is distributed from a private repository, so SPM resolution needs Git credentials with access. Contact havik-support@oddin.gg if you don't have them. Add the HavikPlayer product to your target via Swift Package Manager.
import HavikPlayerHavikPlayerSDK.version reports the SDK version at runtime.
Concepts
What you need
- A publishable api-key provisioned for your app. Your app's bundle id must be on the key's iOS-app allow-list (set at onboarding).
- A match URN (
od:match:…), from your Oddin integration or from the catalog API. - A physical device for DRM playback, because FairPlay does not run in the Simulator.
Authentication: attested sessions
Apps do not send the api-key on streams requests. Instead the SDK exchanges a platform attestation (Apple App Attest) for a short-lived session token via /v1/attest/*, and authenticates every catalog/playback/SSE request with that session, minting and refreshing transparently, including mid-match. The api-key itself is only used to mint sessions and on the DRM license surface.
let client = HavikClient(
baseURL: URL(string: "https://feed-dev.oddin-video.gg")!,
apiKey: "pk_test_your_key",
attestation: HavikClient.defaultAttestation()
)HavikClient.defaultAttestation() picks AppAttestProvider on hardware that supports it and NoAttestationProvider otherwise (Simulator). Keys can be configured to require strict attestation, in which case the Simulator path is rejected by the server.
All authentication denials surface as the same opaque UNAUTHORIZED/FORBIDDEN errors by design; contact support with the requestId if you get stuck.
The play sequence
- Resolve.
GET /v1/playback/{urn}returns aStreamDescriptor: manifest URL, DRM configuration, server time. - Wait for live (optional). With
WaitForLive, the SDK polls politely (server-paced, jittered) until the match is live. - Play. The manifest goes to
AVPlayer; FairPlay keys are delivered throughAVContentKeySessionas the asset requests them. - Stay live. A pushed live-state channel (SSE) plus a manifest staleness watchdog detect end-of-stream; signed license URLs are re-resolved before they expire for long sessions.
DRM
FairPlay only. The descriptor carries the license and certificate URLs; the SDK handles the SPC/CKC exchange, certificate caching, the required X-Device-Id header, and re-resolving the descriptor when its signed license URL ages out. In Mode A this is invisible; in Mode B you attach a ContentKeyCoordinator to your asset.
Mode A: Managed player
HavikPlayer
let player = HavikPlayer(client: client, options: PlayerOptions(
matchUrn: "od:match:1234",
waitForLive: .default
))
player.start()| Member | Description |
|---|---|
init(client:options:) | Construct with a HavikClient and PlayerOptions. |
player: AVPlayer | The underlying AVPlayer; render it with HavikPlayerView or your own layer. |
events: AsyncStream<PlayerEvent> | Everything the player reports, delivered on the main actor. |
start() | Resolve (waiting for live if configured), attach DRM, begin playback. |
stop() | Tear down playback and subscriptions. Clears any terminal state. |
play() / pause() | Transport control. |
setMuted(_:) / setVolume(_:) | Audio control. |
setMaxBitrate(_:) | Cap ABR to renditions at or below this bitrate (nil = uncapped). |
seekToLive() | Jump to the live edge. |
retry() | Recover from a failed state; re-resolves and restarts. |
reload() | Full re-resolve + restart while playing (e.g. after network change). |
PlayerOptions
| Option | Default | Description |
|---|---|---|
matchUrn | — | The match to play (required). |
autoplay | true | Start playback as soon as the item is ready. |
muted | false | Start muted. |
waitForLive | nil | Arm on an upcoming match; attach automatically at go-live. |
liveLatencyTarget | 2 s | Target distance from the live edge. A target AVPlayer honors when the manifest supports it, so measure glass-to-glass rather than assuming parity with the web SDK. |
snapToLiveOnForeground | true | Snap to live when the app foregrounds well behind the edge. |
maxBitrate | nil | Initial ABR cap, bits/sec. |
userId | nil | Optional viewer id, forwarded on the DRM license POST. |
statsInterval | 2 s | Cadence of .stats events. |
licenseRefreshInterval | 480 s | How stale a descriptor may get before its signed license URL is re-resolved. |
liveStateEvents | true | Subscribe to pushed live-state so end-of-stream is detected the instant it happens. |
analyticsEnabled | true | Emit QoE beacons for the beacon plane only. Effective when the playback response carries an analytics sid and the client has a beacons origin; fire-and-forget, never affects playback. CMCD edge-log attribution is not governed by this switch; it follows the server-issued sid alone. |
configuresAudioSession | true | Configure AVAudioSession for playback (audible with the ringer silenced). Set false if the host app owns the audio session. |
PlayerEvent
for await event in player.events {
switch event {
case .stateChanged(let state): …
case .stats(let stats): …
case .error(let error): …
default: break
}
}| Case | Meaning |
|---|---|
.ready | Item loaded and ready to play. |
.waiting(WaitState) | Armed on an upcoming match; payload describes the next poll. |
.playing / .buffering / .paused | Transport transitions. |
.ended | Stream over (pushed by SSE or detected by the staleness watchdog). |
.error(PlaybackError) | Fatal; playback stopped. |
.warning(PlaybackError) | Non-fatal; playback continues (e.g. degraded live-state subscription). |
.pictureInPictureChanged(Bool) | PiP entered/exited. |
.stats(PlaybackStats) | Periodic health sample. |
.stateChanged(PlayerState) | Coarse state transition. |
PlayerState
idle · waiting · loading · playing · buffering · paused · ended · failed, mirroring the web SDK's PlayerState. ended and failed are terminal until stop()/retry().
PlaybackStats
| Field | Description |
|---|---|
observedBitrate | Measured throughput, bits/sec. |
indicatedBitrate | Bitrate of the rendition being served, bits/sec. |
stallCount | Stalls since playback started. |
droppedFrameCount | Dropped frames. |
bufferAhead | Seconds buffered ahead of the playhead. |
liveLatency | Distance from the live edge, seconds (nil until known). |
HavikPlayerView (SwiftUI)
Wraps AVPlayerViewController: system transport controls, full-screen, AirPlay, and Picture-in-Picture come for free.
HavikPlayerView(havik: player)init(havik:showsPlaybackControls:allowsPictureInPicture:onPictureInPictureChange:onFullScreenChange:) or init(player:…) for a raw AVPlayer.
Fullscreen gotcha: AVKit's full-screen presentation covers the hosting SwiftUI view, firing its
onDisappear. Do not stop the player there. TrackonFullScreenChange/onPictureInPictureChangeand only stop when neither presentation is active, or the video blanks the moment fullscreen starts.
Mode B: Bring your own AVPlayer
Resolving
let descriptor = try await client.resolveStream(
matchUrn: "od:match:1234",
waitForLive: .default
)
let asset = AVURLAsset(url: descriptor.manifestUrl)ContentKeyCoordinator
For DRM streams (descriptor.drmEnabled), create a coordinator and attach it before the asset starts loading:
let coordinator = ContentKeyCoordinator(client: client, descriptor: descriptor)
coordinator.attach(to: asset)
let item = AVPlayerItem(asset: asset)
// … your AVPlayer takes it from here. Keep `coordinator` alive!| Member | Description |
|---|---|
init(client:descriptor:userId:licenseRefreshInterval:) | userId rides the license POST; refresh interval defaults to 8 min. |
attach(to:) / detach(from:) | Register/unregister an AVURLAsset with the key session. Attach before loading. |
stop() | End the key session. |
events: AsyncStream<ContentKeyEvent> | Non-fatal notices: .keyDelivered(String), .failed(PlaybackError), .licenseURLRefreshed. |
Retain the coordinator for as long as the asset plays.
AVContentKeySessionholds its delegate weakly, so a coordinator that goes out of scope stops answering key requests, and playback stalls onwaitingForKeywith no error. Create one only whendrmEnabledis true.
LicenseRequest.headers(…) and LicenseRequest.Identity expose the exact license-request headers for hosts that need to observe or proxy the exchange.
Reference
HavikClient
Entry point for catalog discovery, playback resolution, and the live-state event stream. A Sendable value type: create once, pass anywhere.
| Initializer | Use |
|---|---|
init(baseURL:apiKey:attestation:appId:eventsBaseURL:beaconsBaseURL:session:) | The sanctioned mode for apps: attested sessions (see Authentication). appId defaults to the main bundle id. |
init(baseURL:apiKey:eventsBaseURL:beaconsBaseURL:session:) | Fixed-key mode. Works only against keys whose policy allows unattested callers. |
init(baseURL:credential:eventsBaseURL:beaconsBaseURL:session:unauthorizedRecovery:) | Advanced: custom CredentialSource (e.g. your own token plumbing). |
baseURL: the streams API.https://feed-dev.oddin-video.ggfor integration,https://feed.oddin-video.ggfor production.eventsBaseURL: SSE endpoint; defaults to the base host prefixed withevents..beaconsBaseURL: QoE beacon ingest origin (see Analytics). Defaults to the platform hostname conventionfeed[-dev].<domain>→beacons[-dev].<domain>; when the convention doesn't apply and no override is given it isniland the beacon plane stays off (playback is unaffected).HavikClient.defaultAttestation(): App Attest on supporting hardware, app-binding only otherwise.
Catalog
let catalog = try await client.fetchCatalog(CatalogQuery(statuses: [.live]))| API | Returns |
|---|---|
fetchCatalog(_ query:) | Catalog: tournaments with nested matches. flattenedMatches for a flat list. |
fetchTournament(urn:) | One CatalogTournament. |
fetchMatch(urn:) | One CatalogMatch. |
CatalogQuery(sports:statuses:limit:) filters by sport and MatchLiveStatus (upcoming · live · ended).
Live-state events (SSE)
for await event in client.liveStateEvents(matchUrn: urn) {
if case .state(let s) = event, s.state == .ended { … }
}liveStateEvents(matchUrn:minBackoff:maxBackoff:) returns an AsyncStream<LiveStateStreamEvent>:
.state(LiveStateEvent): snapshot on connect, then every transition (live · upcoming · ended · gone)..alive: liveness tick; the subscription is healthy..failure(PlaybackError): non-fatal; the client reconnects with backoff.
Cancel the consuming task to unsubscribe. The managed player consumes this internally when liveStateEvents is enabled.
Analytics: CMCD & QoE beacons
The SDK participates in two analytics planes, both keyed to the per-playback analytics sid: an opaque, non-secret UUID the playback response carries (StreamDescriptor.analytics) when the analytics plane is enabled server-side for the key. No sid → both planes silent. The sid grants nothing (DRM remains the access gate) and is memory-only, never written to disk or to any persistent store.
CMCD edge-log attribution: follows the sid alone. When the descriptor carries a sid, the manifest URL gains the CTA-5004 query-mode CMCD parameter (CMCD=sid%3D%22…%22). AVPlayer offers no per-request CMCD hook, so the sid rides the manifest request only, the same shape as the web SDK's Safari-native fallback. This plane is not governed by analyticsEnabled: it is the platform's edge-log attribution riding an existing request, and the per-customer switch lives server-side (the server includes or omits the sid).
QoE beacons: governed by analyticsEnabled. The managed player posts batched QoE beacons (session start/end, 15-second heartbeats, state transitions, errors) to {beaconsBaseURL}/v1/beacons, one session per sid. Attested clients stamp each batch with X-Havik-Session so ingest can attribute it to a verified client id; a credential failure costs the attribution, never the batch, and the api-key never rides the beacon path. Delivery is fire-and-forget: a lost batch is dropped, never retried, and never affects playback.
Beacons require all three: analyticsEnabled (default true), a sid in the playback response, and a non-nil beaconsBaseURL. Mode B (bring-your-own-player) has no beacon emitter, because beacons are a managed-player feature; the CMCD sid in Mode B is yours to attach (or not) when you build the asset URL.
WaitForLive
Live-pickup policy: how the SDK paces /v1/playback polls for an upcoming match. The cadence is tuned against the server's rate limiter; prefer the defaults.
| Field | Default | Description |
|---|---|---|
timeout | nil | Overall budget before failing with .timeout. Nil = unbounded. |
floor | 1 s | Minimum delay between polls. |
ceiling | 30 s | Maximum delay inside the imminent window. |
kickoffAt | nil | Scheduled kickoff; widens far-future polling. The SDK also reads the server's hint automatically. |
imminentWindow | 600 s | Window before kickoff where cadence tightens. |
sanityCeiling | 300 s | Absolute cap on any single wait. |
jitter | true | Up to +15% jitter so viewers do not poll in lockstep. |
WaitState (in .waiting events / onWait callbacks) reports the phase (tooEarly · unavailable · rateLimited · network) and delay of each wait.
Errors
Every failure is a PlaybackError:
| Field | Description |
|---|---|
code | PlaybackErrorCode (below). |
httpStatus | HTTP status, 0 when the failure never reached HTTP. |
message | Human-readable description. |
retryAfter | Server-requested backoff, when sent. |
requestId | Server request id; quote it to support. |
serverCode | Raw server error code. |
liveStartsAt | Scheduled kickoff, when a 425 body carried it. |
isWaitable | Whether the live-pickup loop would retry this code. |
PlaybackErrorCode (mirrors the web SDK, plus two native-only cases):
| Code | HTTP | Meaning |
|---|---|---|
invalidUrn | 400 | Malformed match URN (terminal). |
notFound | 404 | Unknown URN or not entitled; deliberately indistinguishable (terminal). |
gone | 410 | Ended past the catch-up window (terminal). |
tooEarly | 425 | Upcoming; retried per Retry-After. |
unavailable | 503 | Should-be-live but origin failing; retried with backoff. |
unauthorized | 401 | Bad/absent credential (terminal). |
forbidden | 403 | Surface not allowed / DRM entitlement refused (terminal). |
rateLimited | 429 | Per-IP limiter; backs off per Retry-After. |
internalError | 5xx | Unexpected server error (terminal). |
network | — | Transport failure (retried while waiting for live). |
timeout | — | Gave up waiting for live (terminal). |
aborted | — | Caller cancelled (terminal). |
attestationUnavailable | — | App Attest cannot run on this device (native-only). |
attestationFailed | — | The server rejected the attestation or app binding (native-only). |
Data types
StreamDescriptor: the /v1/playback result: matchUrn, manifestUrl, drmEnabled, drm (DrmInfo), serverTime, liveStartsAt, analytics (AnalyticsInfo), extensions, resolvedAt. fairPlay is a convenience accessor for drm?.fairplay gated on drmEnabled.
DrmInfo: widevine: WidevineInfo? (licenseUrl) and fairplay: FairPlayInfo? (licenseUrl, certificateUrl). License URLs are signed and short-lived (~10 min), and the SDK re-resolves before expiry.
AnalyticsInfo: sid: String?, the per-playback analytics identity. Present only when the analytics plane is enabled server-side; grants nothing and is never persisted.
Credential: apiKey + optional sessionToken. CredentialSource: .fixed(Credential) or .dynamic(async provider).
MatchLiveStatus: upcoming · live · ended (catalog vocabulary).
JSONValue: decoded form of the descriptor's open extensions map.
Attestation
| Type | Role |
|---|---|
AttestationProvider | Protocol: produce AttestationMaterial for a server challenge; handleDenial() may clear cached state and permit ONE retry. |
AppAttestProvider | Apple App Attest implementation (hardware-backed). |
NoAttestationProvider | App-binding only: Simulator/dev, non-strict keys. |
AttestationMaterial | type, attestation (base64 payload), keyId, isAttestation. |
Most apps never touch these directly; use HavikClient.defaultAttestation().
Device identity
HavikDeviceID.current: a stable per-device UUID stored in the Keychain (survives reinstalls; not identifierForVendor), sent as X-Device-Id on every license request. HavikDeviceID.reset() clears it, which is worth surfacing to viewers as a "reset my device identifier" control.
Versioning & support
Semantic versioning on v* tags; HavikPlayerSDK.version at runtime. The error-code and event vocabularies are shared with the web SDK so support runbooks apply across platforms. Questions: havik-support@oddin.gg.