Skip to content

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

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): HavikPlayer owns an AVPlayer and wires live pickup, DRM, retries, stats, and end-of-stream detection automatically. HavikPlayerView renders it in SwiftUI.
  • Mode B (bring your own player): HavikClient.resolveStream + ContentKeyCoordinator, attached to an AVPlayer you 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.

swift
import HavikPlayer

HavikPlayerSDK.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.

swift
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

  1. Resolve. GET /v1/playback/{urn} returns a StreamDescriptor: manifest URL, DRM configuration, server time.
  2. Wait for live (optional). With WaitForLive, the SDK polls politely (server-paced, jittered) until the match is live.
  3. Play. The manifest goes to AVPlayer; FairPlay keys are delivered through AVContentKeySession as the asset requests them.
  4. 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

swift
let player = HavikPlayer(client: client, options: PlayerOptions(
    matchUrn: "od:match:1234",
    waitForLive: .default
))
player.start()
MemberDescription
init(client:options:)Construct with a HavikClient and PlayerOptions.
player: AVPlayerThe 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

OptionDefaultDescription
matchUrnThe match to play (required).
autoplaytrueStart playback as soon as the item is ready.
mutedfalseStart muted.
waitForLivenilArm on an upcoming match; attach automatically at go-live.
liveLatencyTarget2 sTarget 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.
snapToLiveOnForegroundtrueSnap to live when the app foregrounds well behind the edge.
maxBitratenilInitial ABR cap, bits/sec.
userIdnilOptional viewer id, forwarded on the DRM license POST.
statsInterval2 sCadence of .stats events.
licenseRefreshInterval480 sHow stale a descriptor may get before its signed license URL is re-resolved.
liveStateEventstrueSubscribe to pushed live-state so end-of-stream is detected the instant it happens.
analyticsEnabledtrueEmit 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.
configuresAudioSessiontrueConfigure AVAudioSession for playback (audible with the ringer silenced). Set false if the host app owns the audio session.

PlayerEvent

swift
for await event in player.events {
    switch event {
    case .stateChanged(let state):
    case .stats(let stats):
    case .error(let error):
    default: break
    }
}
CaseMeaning
.readyItem loaded and ready to play.
.waiting(WaitState)Armed on an upcoming match; payload describes the next poll.
.playing / .buffering / .pausedTransport transitions.
.endedStream 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

FieldDescription
observedBitrateMeasured throughput, bits/sec.
indicatedBitrateBitrate of the rendition being served, bits/sec.
stallCountStalls since playback started.
droppedFrameCountDropped frames.
bufferAheadSeconds buffered ahead of the playhead.
liveLatencyDistance 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.

swift
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. Track onFullScreenChange / onPictureInPictureChange and only stop when neither presentation is active, or the video blanks the moment fullscreen starts.


Mode B: Bring your own AVPlayer

Resolving

swift
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:

swift
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!
MemberDescription
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.AVContentKeySession holds its delegate weakly, so a coordinator that goes out of scope stops answering key requests, and playback stalls on waitingForKey with no error. Create one only when drmEnabled is 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.

InitializerUse
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.gg for integration, https://feed.oddin-video.gg for production.
  • eventsBaseURL: SSE endpoint; defaults to the base host prefixed with events..
  • beaconsBaseURL: QoE beacon ingest origin (see Analytics). Defaults to the platform hostname convention feed[-dev].<domain>beacons[-dev].<domain>; when the convention doesn't apply and no override is given it is nil and the beacon plane stays off (playback is unaffected).
  • HavikClient.defaultAttestation(): App Attest on supporting hardware, app-binding only otherwise.

Catalog

swift
let catalog = try await client.fetchCatalog(CatalogQuery(statuses: [.live]))
APIReturns
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)

swift
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.

FieldDefaultDescription
timeoutnilOverall budget before failing with .timeout. Nil = unbounded.
floor1 sMinimum delay between polls.
ceiling30 sMaximum delay inside the imminent window.
kickoffAtnilScheduled kickoff; widens far-future polling. The SDK also reads the server's hint automatically.
imminentWindow600 sWindow before kickoff where cadence tightens.
sanityCeiling300 sAbsolute cap on any single wait.
jittertrueUp 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:

FieldDescription
codePlaybackErrorCode (below).
httpStatusHTTP status, 0 when the failure never reached HTTP.
messageHuman-readable description.
retryAfterServer-requested backoff, when sent.
requestIdServer request id; quote it to support.
serverCodeRaw server error code.
liveStartsAtScheduled kickoff, when a 425 body carried it.
isWaitableWhether the live-pickup loop would retry this code.

PlaybackErrorCode (mirrors the web SDK, plus two native-only cases):

CodeHTTPMeaning
invalidUrn400Malformed match URN (terminal).
notFound404Unknown URN or not entitled; deliberately indistinguishable (terminal).
gone410Ended past the catch-up window (terminal).
tooEarly425Upcoming; retried per Retry-After.
unavailable503Should-be-live but origin failing; retried with backoff.
unauthorized401Bad/absent credential (terminal).
forbidden403Surface not allowed / DRM entitlement refused (terminal).
rateLimited429Per-IP limiter; backs off per Retry-After.
internalError5xxUnexpected server error (terminal).
networkTransport failure (retried while waiting for live).
timeoutGave up waiting for live (terminal).
abortedCaller cancelled (terminal).
attestationUnavailableApp Attest cannot run on this device (native-only).
attestationFailedThe 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

TypeRole
AttestationProviderProtocol: produce AttestationMaterial for a server challenge; handleDenial() may clear cached state and permit ONE retry.
AppAttestProviderApple App Attest implementation (hardware-backed).
NoAttestationProviderApp-binding only: Simulator/dev, non-strict keys.
AttestationMaterialtype, 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.

ISC licensed. Bundles hls.js (Apache-2.0).