# @oddin-gg/havik-player

HTML5 player SDK for Oddin live streams: **LL-HLS + DRM** against the
[havik-streams](https://github.com/oddin-gg/havik-streams) API, in three
integration modes:

| Mode                          | Entry                        | You provide            | SDK owns                                           |
| ----------------------------- | ---------------------------- | ---------------------- | -------------------------------------------------- |
| **A (managed)**               | `createPlayer()`             | a `<video>` element    | hls.js, LL-HLS, DRM/EME, retries                   |
| **B (bring your own player)** | `resolveStream()`            | the player + `<video>` | auth → catalog → playback resolution + DRM helpers |
| **C (iframe embed)**          | `mountEmbed()` / hosted page | an iframe slot         | everything, behind a `postMessage` API             |

Widevine (Chrome/Firefox/Edge/Android) is fully supported. Safari/iOS plays via
native passthrough; full FairPlay EME is a tracked fast-follow. See [Limitations](#limitations).

> 📖 **Hosted docs and embed page:** [player-dev.oddin-video.gg](https://player-dev.oddin-video.gg).
> The site pins and bundles this SDK's released npm package; its source is
> [havik-player-site](https://github.com/oddin-gg/havik-player-site).
>
> - **Full API reference:** [docs/API.md](docs/API.md)
> - **Examples** (vanilla / React / Vue): [examples/](examples/)
> - **API stability and versioning:** [docs/STABILITY.md](docs/STABILITY.md)
> - **Service status and endpoint directory:** [status-dev.oddin-video.gg](https://status-dev.oddin-video.gg)

> ⚠️ **Before you integrate.** One prerequisite sits outside your code. The
> api-key's **AllowedOrigins** (and, for iframes, **AllowedIframeParents**) **and**
> the DRM service's **CORS allow-list** must both include every origin you serve
> the player from. Otherwise requests fail their preflight and DRM never starts.
>
> Onboard your origins with Oddin first. Email
> **[havik-support@oddin.gg](mailto:havik-support@oddin.gg)** with the origins you
> need allowlisted, then allow a few minutes for propagation. Never use a `["*"]`
> allow-list in production.

---

## Install

```bash
npm install @oddin-gg/havik-player
```

Or via CDN (`<script>` / iframe), exposing `window.HavikPlayer`:

```html
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player/dist/havik-player.global.js"></script>
```

You need two things to play a stream: a **match URN** (`od:match:…`) and a
**publishable api-key** (`pk_live_…` / `pk_test_…`). The key's
**AllowedOrigins** must include the origin your player runs on. See [Auth & onboarding](#auth--onboarding).

---

## Mode A: Managed player

The SDK owns the `<video>`, bundles hls.js, and wires LL-HLS + Widevine DRM.

```ts
import { createPlayer } from '@oddin-gg/havik-player';

const player = await createPlayer({
  video: document.querySelector('video')!,
  env: 'integration', // or 'production'
  matchUrn: 'od:match:1234',
  credential: { apiKey: 'pk_test_…' },
  autoplay: true,
  muted: true, // required for reliable autoplay
  waitForLive: true, // arm on an upcoming match, auto-play at go-live
});

player.on('statechange', (s) => console.log('state', s)); // loading|waiting|playing|buffering|paused|ended|error
player.on('waiting', (w) => console.log('go-live in', w.retryInMs, 'ms')); // armed countdown
player.on('stats', (s) => console.log(s)); // dropped frames, latency, bitrate, level
player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));

// later
player.destroy();
```

The managed player handles `425 TOO_EARLY` retries (honoring `Retry-After`),
buffering recovery, and silent DRM **license-URL refresh** for long sessions.

### Choosing an environment

`env` names the stack: `integration` is the shared pre-production environment,
`production` is the production one. They are separate deployments, not
aliases. Prefer `env` over a hand-written `baseUrl`. The API host is then
correct by construction, and so is every host derived from it, including the
QoE beacons endpoint.

Pass `baseUrl` instead when the named environments do not cover your
deployment:

- your own CDN or gateway in front of the streams API
- the CN plane, whose host is not under this domain
- a pinned build that has to outlive a domain change

`baseUrl` is not deprecated and is not going away. Pass exactly one of the
two. In TypeScript, passing both or neither is a compile error. In JavaScript
it throws at `createPlayer`, because the two disagreeing is the
misconfiguration worth refusing.

Modes B and C still take a `baseUrl`. Use `baseUrlForEnv` there rather than
copying a hostname into your own config:

```ts
import { baseUrlForEnv, resolveStream } from '@oddin-gg/havik-player';

const stream = await resolveStream({
  baseUrl: baseUrlForEnv('production'),
  matchUrn: 'od:match:1234',
  credential: { apiKey: 'pk_live_…' },
});
```

### Controls & skinning

By default, Mode A renders a **branded, fully skinnable control bar**. It covers
seek, play/pause, volume, a live badge and go-live, quality / audio / subtitle
menus, Picture-in-Picture, fullscreen, buffering and error/retry overlays,
auto-hide, and keyboard a11y. Re-skin it to match your brand with a `theme`, or
by overriding the `--havik-*` CSS variables; or opt out with
`controls: 'native'` / `'none'`.

```ts
await createPlayer({
  video,
  baseUrl,
  matchUrn,
  credential: { apiKey },
  controls: 'custom', // 'custom' (default, branded bar) | 'native' | 'none'
  theme: { accent: '#14b8a6', surface: '#0f172a' }, // merged over the Oddin defaults
});
```

See [Controls & theming](docs/API.md#controls--theming) for the full theme/CSS-variable reference.

## Mode B: Bring your own player

The SDK resolves the stream and gives you the DRM header helpers; you own the
`<video>` and the playback engine.

```ts
import { resolveStream, licenseRequestHeaders, getDeviceId } from '@oddin-gg/havik-player';

const cred = { apiKey: 'pk_test_…' };
const stream = await resolveStream({
  baseUrl: 'https://feed-dev.oddin-video.gg',
  matchUrn: 'od:match:1234',
  credential: cred,
  waitForLive: true,
});

// stream = { manifestUrl, drmEnabled, drm: { widevine: { licenseUrl } }, … }
// Wire into YOUR player. For hls.js, attach the license headers in licenseXhrSetup
// (NOT xhrSetup: EME license requests route through licenseXhrSetup):
const headers = licenseRequestHeaders(stream, cred); // { 'x-api-key', 'X-Match-Urn', 'X-Device-Id', 'Content-Type' }
```

> **DRM rule:** POST to `drm.widevine.licenseUrl` **verbatim**: its signed query
> string _is_ the token. Never rewrite/normalize the URL, and use the **same
> api-key** on playback and license requests or the license 403s.

Discovery helpers (there is no push channel, so polling only):

```ts
import { fetchCatalog, watchStatus } from '@oddin-gg/havik-player';

const catalog = await fetchCatalog({ baseUrl, credential: cred }); // tournaments → matches (metadata only)
const watcher = watchStatus({
  baseUrl,
  matchUrn,
  credential: cred,
  onChange: (m) => console.log(m.status),
});
// watcher.stop();
```

## Mode C: Iframe embed

Drop in the hosted player page and control it over `postMessage`:

```ts
import { mountEmbed } from '@oddin-gg/havik-player';

const embed = mountEmbed({
  container: document.querySelector('#player')!,
  src: 'https://player-dev.oddin-video.gg/embed/', // the hosted embed page
  baseUrl: 'https://feed-dev.oddin-video.gg',
  matchUrn: 'od:match:1234',
  // apiKey is DEV-ONLY here; in production the hosted page injects it server-side.
  onEvent: (msg) => console.log(msg), // { type: 'oddin:ready' | 'oddin:state' | 'oddin:waiting' | 'oddin:stats' | 'oddin:error' }
});

embed.play();
embed.setMuted(false);
// embed.destroy();
```

Both ends verify `event.origin` on every message. The embed page reads its
config from `window.HAVIK_EMBED_CONFIG` (server-injected) or the URL query.
A ready-to-host template lives in the repo at [`embed/index.html`](embed/index.html) (not part of the npm package).

> **Iframe auth:** the key's **AllowedIframeParents** must include the embedding
> page's origin. Iframe navigations send a `Referer`/`Sec-Fetch-Dest: iframe`,
> which is what havik-streams matches against; there is no `Origin` header on a
> top-level iframe nav.

The injected config also takes `analytics`, forwarded to the managed player.
Set it when `baseUrl` is not a `feed` host, or QoE beacons stay off. See
[Analytics](#analytics-qoe-beacons).

```js
window.HAVIK_EMBED_CONFIG = {
  baseUrl: 'https://cdn.example.com',
  matchUrn: 'od:match:1234',
  apiKey: 'pk_test_…',
  parentOrigin: 'https://your-site.example',
  analytics: { endpoint: 'https://beacons.oddin-video.gg' },
};
```

`analytics` is read from the injected config only. It names a telemetry sink,
so the query string is not allowed to set one.

That has a consequence worth knowing before you pick a Mode C path.
`mountEmbed` passes its config to the embed page as query parameters, so it
cannot carry `analytics` either. If your `baseUrl` is not a `feed` host and
you need beacons, host the embed page yourself and inject the config. A
`mountEmbed` integration on a non-feed base has no way to turn analytics on.

---

## Live pickup (`waitForLive`)

A live match isn't playable until the stream is actually live. With `waitForLive`,
the player arms and auto-attaches the instant it goes live, with no user action.

**By default it does this over a push channel (SSE), not polling.** While the
live-state stream is connected the player makes **zero** `/v1/playback` requests.
It waits for the pushed go-live, then resolves once, so it doesn't repeatedly poll
while waiting for kickoff. If the push channel is unavailable it transparently falls back
to **polling** `425 TOO_EARLY` + `Retry-After` (scaled ~30s→1s near kickoff, never
faster than the server asks). Set `liveStateEvents: false` to force polling.

```ts
waitForLive: {
  timeoutMs: 30 * 60_000, // overall arm budget (default: unbounded)
  floorMs: 1000,          // min poll interval (default 1000, server minimum)
  ceilMs: 30_000,         // cap far-from-kickoff backoff
  onState: (s) => console.log(s.phase, s.retryInMs, s.attempt),
}
```

**End of stream** is detected automatically: when the live stream finishes the
player stops and fires `ended`. It never sits buffering on a stream that's over. See
[`subscribeLiveState`](docs/API.md#subscribelivestate) to consume the live-state
channel directly.

## Error handling

All API errors are a typed `PlaybackError` with `{ code, httpStatus, retryAfterMs?, requestId? }`:

| code                         | HTTP                | meaning                                                                                                                                      | retry?                                                    |
| ---------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `INVALID_URN`                | 400                 | malformed URN                                                                                                                                | no                                                        |
| `NOT_FOUND`                  | 404                 | unknown URN **or** not entitled (indistinguishable)                                                                                          | no                                                        |
| `GONE`                       | 410                 | ended past catchup window                                                                                                                    | no                                                        |
| `TOO_EARLY`                  | 425                 | upcoming, not live yet                                                                                                                       | yes (waitForLive)                                         |
| `UNAVAILABLE`                | 503                 | should-be-live, origin warming up                                                                                                            | yes                                                       |
| `UNAUTHORIZED` / `FORBIDDEN` | 401 / 403           | bad key / origin not allowed / DRM rejected **server-side**                                                                                  | no                                                        |
| `DRM_CLIENT`                 | 0 or license status | **client-side** DRM failure: key system unusable on this platform, CDM/EME error, or license transport exhausted (not an entitlement denial) | sometimes; the control bar offers retry (see docs/API.md) |
| `RATE_LIMITED`               | 429                 | per-IP limiter                                                                                                                               | back off                                                  |

---

## Auth & onboarding

The `x-api-key` is a **publishable** key, by design embeddable in browser JS. Its
only guardrail is the **server-side per-key allowlist**, so onboarding each
integrator origin is a hard dependency:

1. **havik-streams**: the key's `AllowedOrigins` must include every origin your
   player runs on (and `AllowedIframeParents` for Mode C). Never ship `["*"]` to
   prod; it disables the guardrail.
2. **havik-drm (`drm-proxy`)**: its `CORS_ALLOWED_ORIGINS` must also include those
   origins, or the cross-origin license POST fails its preflight and DRM never
   starts (silent black video).
3. Scope the key's entitlements to the tournaments you serve.

**Where to send onboarding requests:** email
[havik-support@oddin.gg](mailto:havik-support@oddin.gg) with the origins you need
allowlisted, and the iframe parents for Mode C. Include the tournaments you expect
to serve. Steps 1 and 2 are applied on our side; you cannot self-serve them.

Revocation isn't instant (≤5min validation cache + ≤24h rotation grace).

**Forward-compatible credentials:** `credential` also accepts an async function
`() => Promise<{ apiKey }>`, so you can later swap in a short-lived-token mint
service without changing your integration. (No such service exists today.)

---

## Demo app

A full catalog-browser + live-watch + iframe demo lives in `demo/`:

```bash
cp demo/.env.local.example demo/.env.local   # set VITE_HAVIK_BASE_URL + VITE_HAVIK_API_KEY
npm run dev                                   # http://localhost:5173
```

The key's `AllowedOrigins` must include `http://localhost:5173`.

---

## Limitations

- **FairPlay (Safari/iOS)**: not implemented in v1. Safari uses native HLS
  passthrough, which only plays DRM where the OS already trusts the origins. Full
  FairPlay EME (via a Shaka adapter behind the `PlaybackEngine` seam) is a tracked
  fast-follow.
- **HLS only** (no DASH); **Widevine + FairPlay** key systems (no PlayReady; Edge
  uses Widevine). Container CMAF/fMP4, encryption cbcs.
- hls.js is pinned exactly (see `package.json` for the current version).
  LL-HLS+DRM playback is sensitive to hls.js regressions, so bumps are
  deliberate and tested, never automatic.

## Analytics (QoE beacons)

Mode A emits anonymous QoE beacons to the Havik analytics plane: startup time,
heartbeat byte and quality deltas, state changes, errors, and session end. They
are correlated with CDN logs via a server-minted CMCD session id (`sid`). The
`sid` lives **only in JS memory**, never in storage or cookies, and dies with the
player. Opt out or
redirect with the `analytics` option on `createPlayer`
(`analytics: false | { endpoint?: string; idleMs?: number }`); without a
server-minted `sid` the emitter is inert.

A session closes itself
(`END_REASON_IDLE`) after `idleMs` of sitting paused. The default is 10
minutes, and `0` disables it. The same close happens when the page was
suspended (OS sleep, a frozen tab) for that long. The suspension gap never
goes below five minutes, whatever `idleMs` is. The next `play()` reloads the
stream as a fresh session.

### If your `baseUrl` is not a `feed` host, set `analytics.endpoint`

By default the beacons host is derived from `baseUrl` by hostname convention:
`feed.<domain>` becomes `beacons.<domain>`, and `feed-dev.<domain>` becomes
`beacons-dev.<domain>`. The rule is deliberately narrow. It maps only hosts
whose first label is `feed` or `feed-*`. **Any other `baseUrl` derives nothing,
and beacons are disabled.** This usually happens when your own CDN or gateway
hostname fronts the streams API:

```js
// No beacons: 'cdn.example.com' is not a feed host, so there is
// nothing to derive and no endpoint was given. Playback is unaffected.
createPlayer({
  video,
  baseUrl: 'https://cdn.example.com',
  matchUrn: 'od:match:1234',
  credential: { apiKey: 'pk_test_…' },
});

// Beacons flow: name the host the derivation cannot guess.
createPlayer({
  video,
  baseUrl: 'https://cdn.example.com',
  matchUrn: 'od:match:1234',
  credential: { apiKey: 'pk_test_…' },
  analytics: { endpoint: 'https://beacons.oddin-video.gg' },
});
```

Playback is unaffected, so nothing on screen changes. Only the analytics plane
stays empty. The SDK therefore says so instead of failing quietly. It prints a
`console.warn` naming the offending host, once per host per page. It also emits
a non-fatal [`warning`](docs/API.md#player-events) event carrying the same
sentence, so you can alert on it without watching a browser console.

Ask Oddin for the beacons host that matches your deployment. A guessed host
delivers nothing, just as quietly.

## Privacy

The SDK persists a random per-device id in `localStorage` and sends it as
`X-Device-Id` on every DRM license request (required by the license server). It is a
stable cross-session identifier, so disclose it in your privacy policy and offer a
reset via [`clearDeviceId()`](docs/API.md#getdeviceid--cleardeviceid). The QoE
beacons above are anonymous. No IP is stored, the user agent is minimized on
arrival, and the in-memory `sid` is not a cross-session identifier. No other personal data is
collected by the SDK.

## Browser & runtime support

- **Browser-only.** Importing the package is side-effect-safe (verified in CI),
  but the SDK must be used **client-side**. Call `createPlayer`/`mountEmbed` in
  the browser, not during SSR; in Next.js, dynamic-import it in a client
  component.
- Supported targets: see [`.browserslistrc`](.browserslistrc) (Chrome/Edge ≥94,
  Firefox ≥91, Safari/iOS ≥15). DRM = Widevine on Chrome/Edge/Firefox/Android;
  FairPlay (Safari/iOS) is not yet implemented.

## Development

```bash
npm run build       # ESM lib (dist/index.js) + IIFE bundle (dist/havik-player.global.js)
npm run typecheck
npm run lint
npm test            # vitest
npm run test:coverage
npm run dev         # demo app
```

## Security

See [SECURITY.md](SECURITY.md) for the vulnerability-disclosure policy.

## License

ISC; see [LICENSE](LICENSE).

### Third-party licenses

This package depends on, and the CDN bundle (`dist/havik-player.global.js`) statically
embeds, **hls.js** ([Apache-2.0](https://github.com/video-dev/hls.js/blob/master/LICENSE),
© Dailymotion). Full attribution is in [THIRD_PARTY_NOTICES.txt](THIRD_PARTY_NOTICES.txt),
which ships with the package and whose attribution is preserved in the bundle banner.

DRM uses the browser's built-in EME/CDM. Widevine™ (Google), FairPlay®/AirPlay®
(Apple) and PlayReady (Microsoft) are trademarks of their respective owners, and
no CDM is distributed with this package.
